api ulandi
This commit is contained in:
@@ -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
59
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
BIN
public/Logo_blue.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
BIN
public/navLogo.png
Normal file
BIN
public/navLogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 qo‘shish")}
|
<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}
|
||||||
{cat.name}
|
className="flex gap-2 mb-4 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-gray-600 scrollbar-track-gray-800"
|
||||||
</TabsTrigger>
|
>
|
||||||
))}
|
{category.map((cat) => (
|
||||||
</TabsList>
|
<TabsTrigger
|
||||||
|
key={cat.id}
|
||||||
|
value={String(cat.id)}
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{cat.name}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
{hasNextPage && (
|
||||||
|
<div ref={loaderRef} className="flex items-center px-4">
|
||||||
|
{isFetchingNextPage ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tabs content */}
|
{/* 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 bo‘limda savollar yo‘q.")}
|
{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 qo‘shish")}
|
{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 o‘chirmoqchimisiz?")}</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>
|
||||||
|
|||||||
@@ -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")
|
||||||
) : (
|
) : (
|
||||||
"Qo‘shish"
|
t("Qo‘shish")
|
||||||
)}
|
)}
|
||||||
</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 o‘chirmoqchimisiz?</DialogTitle>
|
<DialogTitle>{t("Haqiqatan ham o‘chirmoqchimisiz?")}</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("O‘chirish")
|
t("O'chirish")
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
42
src/pages/finance/lib/api.ts
Normal file
42
src/pages/finance/lib/api.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type {
|
||||||
|
UserOrderData,
|
||||||
|
UserOrderDetailData,
|
||||||
|
} from "@/pages/finance/lib/type";
|
||||||
|
import httpClient from "@/shared/config/api/httpClient";
|
||||||
|
import { USER_ORDERS } from "@/shared/config/api/URLs";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
|
||||||
|
const getAllOrder = async (params: {
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}): Promise<AxiosResponse<UserOrderData>> => {
|
||||||
|
const res = await httpClient.get(USER_ORDERS, { params });
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDetailOrder = async (
|
||||||
|
id: number,
|
||||||
|
): Promise<AxiosResponse<UserOrderDetailData>> => {
|
||||||
|
const res = await httpClient.get(`${USER_ORDERS}${id}/`);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDetailOrder = async ({
|
||||||
|
id,
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
body: {
|
||||||
|
order_status:
|
||||||
|
| "pending_payment"
|
||||||
|
| "pending_confirmation"
|
||||||
|
| "cancelled"
|
||||||
|
| "confirmed"
|
||||||
|
| "completed";
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const res = await httpClient.patch(`${USER_ORDERS}${id}/`, body);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { getAllOrder, getDetailOrder, updateDetailOrder };
|
||||||
106
src/pages/finance/lib/type.ts
Normal file
106
src/pages/finance/lib/type.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
export interface UserOrderData {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
links: {
|
||||||
|
previous: string;
|
||||||
|
next: string;
|
||||||
|
};
|
||||||
|
total_items: number;
|
||||||
|
total_pages: number;
|
||||||
|
page_size: number;
|
||||||
|
current_page: number;
|
||||||
|
results: {
|
||||||
|
orders: {
|
||||||
|
id: number;
|
||||||
|
user: {
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
contact: string;
|
||||||
|
};
|
||||||
|
destination: string;
|
||||||
|
total_price: number;
|
||||||
|
order_status:
|
||||||
|
| "pending_payment"
|
||||||
|
| "pending_confirmation"
|
||||||
|
| "cancelled"
|
||||||
|
| "confirmed"
|
||||||
|
| "completed";
|
||||||
|
tour_name: string;
|
||||||
|
}[];
|
||||||
|
total_income: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderStatus {
|
||||||
|
order_status:
|
||||||
|
| "pending_payment"
|
||||||
|
| "pending_confirmation"
|
||||||
|
| "cancelled"
|
||||||
|
| "confirmed"
|
||||||
|
| "completed";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserOrderDetailData {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
id: number;
|
||||||
|
user: {
|
||||||
|
id: number;
|
||||||
|
last_login: string;
|
||||||
|
is_superuser: true;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
is_staff: true;
|
||||||
|
is_active: true;
|
||||||
|
date_joined: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
avatar: string;
|
||||||
|
validated_at: string;
|
||||||
|
role: string;
|
||||||
|
total_spent: number;
|
||||||
|
travel_agency: number;
|
||||||
|
};
|
||||||
|
departure: string;
|
||||||
|
destination: string;
|
||||||
|
departure_date: string;
|
||||||
|
arrival_time: string;
|
||||||
|
participant: [
|
||||||
|
{
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
birth_date: string;
|
||||||
|
phone_number: string;
|
||||||
|
gender: "male" | "female";
|
||||||
|
participant_pasport_image: [
|
||||||
|
{
|
||||||
|
image: string;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
];
|
||||||
|
ticket: number;
|
||||||
|
tariff: string;
|
||||||
|
transport: string;
|
||||||
|
extra_service: [
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
extra_paid_service: [
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
order_status:
|
||||||
|
| "pending_payment"
|
||||||
|
| "pending_confirmation"
|
||||||
|
| "cancelled"
|
||||||
|
| "confirmed"
|
||||||
|
| "completed";
|
||||||
|
total_price: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
"use client";
|
"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 to‘lovlar")}
|
{t("Kutilayotgan to‘lovlar")}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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,82 +175,114 @@ 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
|
<div className="flex justify-between items-start mb-4">
|
||||||
key={purchase.id}
|
<div>
|
||||||
className="bg-gray-700 p-6 rounded-lg hover:bg-gray-600 transition-colors"
|
<h3 className="text-lg font-bold text-gray-100">
|
||||||
>
|
{data?.departure}
|
||||||
<div className="flex justify-between items-start mb-4">
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{data && getStatusBadge(data?.order_status)}
|
||||||
|
{data && (
|
||||||
|
<Select
|
||||||
|
onValueChange={(
|
||||||
|
value:
|
||||||
|
| "pending_payment"
|
||||||
|
| "pending_confirmation"
|
||||||
|
| "cancelled"
|
||||||
|
| "confirmed"
|
||||||
|
| "completed",
|
||||||
|
) =>
|
||||||
|
mutate({
|
||||||
|
id: data.id,
|
||||||
|
body: { order_status: value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
value={data.order_status}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px] bg-gray-800 border-gray-700 text-gray-200">
|
||||||
|
<SelectValue placeholder={t("Statusni tanlang")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-gray-800 text-gray-100 border-gray-700">
|
||||||
|
<SelectItem value="pending_payment">
|
||||||
|
{t("Pending")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="pending_confirmation">
|
||||||
|
{t("Paid")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="confirmed">
|
||||||
|
{t("Refunded")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="completed">
|
||||||
|
{t("Completed")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="cancelled">
|
||||||
|
{t("Cancelled")}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="w-4 h-4 text-gray-400" />
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-bold text-gray-100">
|
<p className="text-sm text-gray-400">
|
||||||
{purchase.tourName}
|
{t("Destination")}
|
||||||
</h3>
|
</p>
|
||||||
<p className="text-gray-400 text-sm">
|
<p className="text-gray-100">{data?.destination}</p>
|
||||||
{t("Booking Ref")}: {purchase.bookingReference}
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4 text-gray-400" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
{t("Travel Dates")}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-100">
|
||||||
|
{data?.departure_date} - {data?.arrival_time}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{getStatusBadge(purchase.paymentStatus)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<User className="w-4 h-4 text-gray-400" />
|
||||||
<MapPin className="w-4 h-4 text-gray-400" />
|
<div>
|
||||||
<div>
|
<p className="text-sm text-gray-400">
|
||||||
<p className="text-sm text-gray-400">
|
{t("Travelers")}
|
||||||
{t("Destination")}
|
</p>
|
||||||
</p>
|
<p className="text-gray-100">
|
||||||
<p className="text-gray-100">
|
{data?.participant.length}
|
||||||
{purchase.destination}
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Calendar className="w-4 h-4 text-gray-400" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-400">
|
|
||||||
{t("Travel Dates")}
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-100">
|
|
||||||
{purchase.travelDate} - {purchase.returnDate}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<User className="w-4 h-4 text-gray-400" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-400">
|
|
||||||
{t("Travelers")}
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-100">{purchase.travelers}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<DollarSign className="w-4 h-4 text-gray-400" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-400">{t("Amount")}</p>
|
|
||||||
<p className="text-green-400 font-bold">
|
|
||||||
{formatPrice(purchase.amount, true)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center pt-4 border-t border-gray-600">
|
<div className="flex items-center gap-2">
|
||||||
<div className="text-sm text-gray-400">
|
<DollarSign className="w-4 h-4 text-gray-400" />
|
||||||
{t("Booked on")} {purchase.purchaseDate}{" "}
|
<div>
|
||||||
{getPaymentMethod(purchase.paymentMethod)}
|
<p className="text-sm text-gray-400">{t("Amount")}</p>
|
||||||
|
<p className="text-green-400 font-bold">
|
||||||
|
{data && formatPrice(data?.total_price, true)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
|
<div className="flex justify-between items-center pt-4 border-t border-gray-600">
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
{t("Booked on")} {data?.departure_date}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
|
{data?.user.email && (
|
||||||
<Mail className="w-5 h-5 text-yellow-400" />
|
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
|
||||||
<div>
|
<Mail className="w-5 h-5 text-yellow-400" />
|
||||||
<p className="text-sm text-gray-400">
|
<div>
|
||||||
{t("Email Address")}
|
<p className="text-sm text-gray-400">
|
||||||
</p>
|
{t("Email Address")}
|
||||||
<p className="text-gray-100">
|
</p>
|
||||||
{mockUserData.userEmail}
|
<p className="text-gray-100">{data?.user.email}</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
|
|
||||||
<Calendar className="w-5 h-5 text-purple-400" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-400">
|
|
||||||
{t("Member Since")}
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-100">{mockUserData.joinDate}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6">
|
||||||
{/* Travel Preferences */}
|
<h2 className="text-lg font-semibold text-slate-100 mb-4 flex items-center gap-2">
|
||||||
<div>
|
<UsersIcon className="w-5 h-5 text-purple-400" />
|
||||||
<h3 className="text-lg font-bold mb-4">
|
{t("Hamrohlar")}
|
||||||
{t("Travel Statistics")}
|
<span className="ml-auto text-sm font-normal text-slate-500">
|
||||||
</h3>
|
{data?.participant.length} ta
|
||||||
<div className="space-y-4">
|
</span>
|
||||||
<div className="p-4 bg-gray-700 rounded-lg">
|
</h2>
|
||||||
<p className="text-sm text-gray-400 mb-2">
|
<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">
|
||||||
{t("Favorite Destination")}
|
{data && data?.participant.length > 0 ? (
|
||||||
|
data?.participant.map((companion, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="p-4 bg-slate-800/50 rounded-xl border border-slate-700/50"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-14 h-14 rounded-lg bg-gradient-to-br from-purple-600 to-pink-600 flex items-center justify-center flex-shrink-0 shadow-lg shadow-purple-500/20">
|
||||||
|
<User className="w-7 h-7 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-slate-100 text-lg">
|
||||||
|
{companion.first_name} {companion.last_name}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 text-xs rounded-full border ${
|
||||||
|
companion.gender === "male"
|
||||||
|
? "bg-blue-500/20 text-blue-400 border-blue-500/30"
|
||||||
|
: "bg-pink-500/20 text-pink-400 border-pink-500/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{companion.gender === "male"
|
||||||
|
? t("Erkak")
|
||||||
|
: t("Ayol")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{t("Tug'ilgan sana")}
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-200 font-medium">
|
||||||
|
{companion.birth_date}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{t("Telefon raqami")}
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-200 font-medium">
|
||||||
|
{formatPhone(companion.phone_number)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{companion.participant_pasport_image.length >
|
||||||
|
0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 mb-2">
|
||||||
|
{t("Passport rasmlari")}:
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{companion.participant_pasport_image.map(
|
||||||
|
(img) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="w-20 h-20 rounded-lg bg-slate-700/50 flex items-center justify-center overflow-hidden border border-slate-600/50"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt="passport image"
|
||||||
|
src={img.image}
|
||||||
|
className="w-full h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-500 text-center py-4">
|
||||||
|
{t("Hozircha hamrohlar qo'shilmagan")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-100 font-medium">Dubai, UAE</p>
|
)}
|
||||||
<p className="text-sm text-gray-400 mt-1">
|
|
||||||
2 {t("bookings")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 bg-gray-700 rounded-lg">
|
|
||||||
<p className="text-sm text-gray-400 mb-2">
|
|
||||||
{t("Preferred Agency")}
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-100 font-medium">
|
|
||||||
Silk Road Travel
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-400 mt-1">
|
|
||||||
2 {t("out of")} 3 {t("bookings")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 bg-gray-700 rounded-lg">
|
|
||||||
<p className="text-sm text-gray-400 mb-2">
|
|
||||||
{t("Average Booking Value")}
|
|
||||||
</p>
|
|
||||||
<p className="text-green-400 font-bold">
|
|
||||||
{formatPrice(totalSpent, true)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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 qo‘shish
|
// ✅ reset faqat bir marta, ma'lumot tayyor bo'lganda ishlaydi
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
if (
|
||||||
const viewport = document.querySelector(
|
detail &&
|
||||||
"[data-radix-select-viewport]",
|
allCategories.length > 0 &&
|
||||||
) as HTMLDivElement | null;
|
!hasReset.current // faqat bir marta
|
||||||
|
) {
|
||||||
|
const foundCategory = allCategories.find(
|
||||||
|
(cat) => cat.name === detail.category.name,
|
||||||
|
);
|
||||||
|
|
||||||
if (viewport) {
|
form.reset({
|
||||||
const handleScroll = () => {
|
banner: detail.image as any,
|
||||||
const { scrollTop, scrollHeight, clientHeight } = viewport;
|
category: foundCategory ? String(foundCategory.id) : "",
|
||||||
if (
|
title: detail.title_uz,
|
||||||
scrollHeight - scrollTop - clientHeight < 50 &&
|
title_ru: detail.title_ru,
|
||||||
hasNextPage &&
|
desc: detail.text_uz,
|
||||||
!isFetchingNextPage
|
desc_ru: detail.text_ru,
|
||||||
) {
|
});
|
||||||
fetchNextPage();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
viewport.addEventListener("scroll", handleScroll);
|
hasReset.current = true; // ✅ qayta reset bo‘lmasin
|
||||||
clearInterval(interval);
|
}
|
||||||
|
}, [detail, allCategories, form]);
|
||||||
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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
formData.append(`post_images[${i}]`, section.image);
|
if (section.image instanceof File) {
|
||||||
|
formData.append(`post_images[${i}]`, section.image);
|
||||||
|
}
|
||||||
formData.append(`post_text[${i}]`, section.text);
|
formData.append(`post_text[${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) {
|
||||||
added(formData);
|
update({
|
||||||
|
body: formData,
|
||||||
|
id: Number(id),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
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
40
src/pages/seo/lib/api.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { AllSeoData, DetailSeoData } from "@/pages/seo/lib/types";
|
||||||
|
import httpClient from "@/shared/config/api/httpClient";
|
||||||
|
import { SITE_SEO } from "@/shared/config/api/URLs";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
|
||||||
|
const createSeo = async (body: FormData) => {
|
||||||
|
const res = await httpClient.post(SITE_SEO, body, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAllSeo = async (params: {
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}): Promise<AxiosResponse<AllSeoData>> => {
|
||||||
|
const res = await httpClient.get(SITE_SEO, { params });
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDetailSeo = async (
|
||||||
|
id: number,
|
||||||
|
): Promise<AxiosResponse<DetailSeoData>> => {
|
||||||
|
const res = await httpClient.get(`${SITE_SEO}${id}/`);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSeo = async ({ body, id }: { id: number; body: FormData }) => {
|
||||||
|
const res = await httpClient.patch(`${SITE_SEO}${id}/`, body, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSeo = async ({ id }: { id: number }) => {
|
||||||
|
const res = await httpClient.delete(`${SITE_SEO}${id}/`);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { createSeo, deleteSeo, getAllSeo, getDetailSeo, updateSeo };
|
||||||
37
src/pages/seo/lib/types.ts
Normal file
37
src/pages/seo/lib/types.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export interface AllSeoData {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
links: {
|
||||||
|
previous: string;
|
||||||
|
next: string;
|
||||||
|
};
|
||||||
|
total_items: number;
|
||||||
|
total_pages: number;
|
||||||
|
page_size: number;
|
||||||
|
current_page: number;
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
keywords: string;
|
||||||
|
og_title: string;
|
||||||
|
og_description: string;
|
||||||
|
og_image: string;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetailSeoData {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
keywords: string;
|
||||||
|
og_title: string;
|
||||||
|
og_description: string;
|
||||||
|
og_image: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,13 +1,26 @@
|
|||||||
"use client";
|
"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("Ma’lumotlar muvaffaqiyatli saqlandi"), {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
setFormData({
|
||||||
|
description: "",
|
||||||
|
keywords: "",
|
||||||
|
ogDescription: "",
|
||||||
|
ogImage: null,
|
||||||
|
ogTitle: "",
|
||||||
|
title: "",
|
||||||
|
});
|
||||||
|
queryClient.refetchQueries({ queryKey: ["seo_all"] });
|
||||||
|
queryClient.refetchQueries({ queryKey: ["seo_detail"] });
|
||||||
|
setImagePreview(null);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
position: "top-center",
|
||||||
|
richColors: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: editSeo, isPending: editPending } = useMutation({
|
||||||
|
mutationFn: async ({ body, id }: { body: FormData; id: number }) =>
|
||||||
|
updateSeo({ body, id }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("Ma’lumotlar muvaffaqiyatli saqlandi"), {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
setEdit(null);
|
||||||
|
setFormData({
|
||||||
|
description: "",
|
||||||
|
keywords: "",
|
||||||
|
ogDescription: "",
|
||||||
|
ogImage: null,
|
||||||
|
ogTitle: "",
|
||||||
|
title: "",
|
||||||
|
});
|
||||||
|
queryClient.refetchQueries({ queryKey: ["seo_all"] });
|
||||||
|
queryClient.refetchQueries({ queryKey: ["seo_detail"] });
|
||||||
|
setImagePreview(null);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
position: "top-center",
|
||||||
|
richColors: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: deleteSeoo } = useMutation({
|
||||||
|
mutationFn: (id: number) => deleteSeo({ id }),
|
||||||
|
onSuccess: () => {
|
||||||
|
setEdit(null);
|
||||||
|
setFormData({
|
||||||
|
description: "",
|
||||||
|
keywords: "",
|
||||||
|
ogDescription: "",
|
||||||
|
ogImage: null,
|
||||||
|
ogTitle: "",
|
||||||
|
title: "",
|
||||||
|
});
|
||||||
|
queryClient.refetchQueries({ queryKey: ["seo_all"] });
|
||||||
|
queryClient.refetchQueries({ queryKey: ["seo_detail"] });
|
||||||
|
setImagePreview(null);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
position: "top-center",
|
||||||
|
richColors: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: allSeo, isLoading } = useQuery({
|
||||||
|
queryKey: ["seo_all"],
|
||||||
|
queryFn: () => getAllSeo({ page: 1, page_size: 99 }),
|
||||||
|
select(data) {
|
||||||
|
return data.data.data.results;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: detailSeo } = useQuery({
|
||||||
|
queryKey: ["seo_detail", edit],
|
||||||
|
queryFn: () => getDetailSeo(edit!),
|
||||||
|
select(data) {
|
||||||
|
return data.data.data;
|
||||||
|
},
|
||||||
|
enabled: !!edit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [savedSeo, setSavedSeo] = useState<SeoData | null>(null);
|
|
||||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
const [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 bo‘lsa qo‘shamiz
|
||||||
|
if (formData.ogImage instanceof File) {
|
||||||
|
form.append("og_image", formData.ogImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (edit) {
|
||||||
|
editSeo({ body: form, id: edit });
|
||||||
|
} else {
|
||||||
|
mutate(form);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTitleLength = () => formData.title?.length ?? 0;
|
||||||
|
const getDescriptionLength = () => formData.description?.length ?? 0;
|
||||||
|
|
||||||
|
const isValidTitle = getTitleLength() > 10 && getTitleLength() <= 60;
|
||||||
const isValidDescription =
|
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 (30–60 belgi)"
|
placeholder={t("Sahifa sarlavhasi (30–60 belgi)")}
|
||||||
className="w-full bg-slate-700 text-white px-4 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
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 (120–160 belgi)"
|
placeholder={t("Sahifa tavsifi (120–160 belgi)")}
|
||||||
className="w-full bg-slate-700 text-white px-4 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
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"
|
||||||
>
|
>
|
||||||
O‘chirish
|
{t("O'chirish")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -236,33 +368,73 @@ 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-4">
|
||||||
<h3 className="text-lg font-semibold mb-2">
|
{t("Saqlangan SEO Ma’lumotlari")}
|
||||||
Saqlangan SEO Ma’lumotlari
|
</h3>
|
||||||
</h3>
|
|
||||||
<pre className="bg-slate-800 p-4 rounded text-xs overflow-auto">
|
{isLoading ? (
|
||||||
{JSON.stringify(
|
<p>{t("Yuklanmoqda...")}</p>
|
||||||
{
|
) : allSeo && allSeo.length > 0 ? (
|
||||||
...savedSeo,
|
<div className="space-y-4 max-h-[400px] overflow-y-auto">
|
||||||
ogImage: savedSeo.ogImage
|
{allSeo.map((seo) => (
|
||||||
? savedSeo.ogImage.substring(0, 100) + "..."
|
<div
|
||||||
: "",
|
key={seo.id}
|
||||||
},
|
className="bg-slate-800 p-4 rounded-lg border border-slate-600 relative"
|
||||||
null,
|
>
|
||||||
2,
|
<div className="absolute top-2 right-2 flex gap-2">
|
||||||
)}
|
<button
|
||||||
</pre>
|
onClick={() => handleEdit(seo.id)}
|
||||||
</div>
|
className="p-1 text-blue-400 hover:text-blue-600"
|
||||||
)}
|
>
|
||||||
|
<Pencil size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(seo.id)}
|
||||||
|
className="p-1 text-red-400 hover:text-red-600"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 className="text-white font-semibold">{seo.title}</h4>
|
||||||
|
<p className="text-sm text-slate-300">
|
||||||
|
{seo.description?.substring(0, 150)}...
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
<strong>{t("Keywords")}:</strong> {seo.keywords}
|
||||||
|
</p>
|
||||||
|
{seo.og_image && (
|
||||||
|
<img
|
||||||
|
src={seo.og_image}
|
||||||
|
alt="OG"
|
||||||
|
className="w-full h-40 object-cover rounded-lg mt-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-slate-400">
|
||||||
|
{t("Hozircha SEO ma’lumotlari mavjud emas.")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
47
src/pages/site-banner/lib/api.ts
Normal file
47
src/pages/site-banner/lib/api.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type {
|
||||||
|
AllGetBanner,
|
||||||
|
DetailGetBanner,
|
||||||
|
} from "@/pages/site-banner/lib/types";
|
||||||
|
import httpClient from "@/shared/config/api/httpClient";
|
||||||
|
import { BANNER } from "@/shared/config/api/URLs";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
|
||||||
|
const getBanner = async (params: {
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}): Promise<AxiosResponse<AllGetBanner>> => {
|
||||||
|
const res = await httpClient.get(BANNER, { params });
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBannerDetail = async (
|
||||||
|
id: number,
|
||||||
|
): Promise<AxiosResponse<DetailGetBanner>> => {
|
||||||
|
const res = await httpClient.get(`${BANNER}${id}/`);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const bannerDelete = async (id: number) => {
|
||||||
|
const res = await httpClient.delete(`${BANNER}${id}/`);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createBanner = async (body: FormData) => {
|
||||||
|
const res = await httpClient.post(BANNER, body, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateBanner = async ({ body, id }: { id: number; body: FormData }) => {
|
||||||
|
const res = await httpClient.patch(`${BANNER}${id}/`, body, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { bannerDelete, createBanner, getBanner, getBannerDetail, updateBanner };
|
||||||
37
src/pages/site-banner/lib/types.ts
Normal file
37
src/pages/site-banner/lib/types.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export interface DetailGetBanner {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
title_ru: string;
|
||||||
|
title_uz: string;
|
||||||
|
description: string;
|
||||||
|
description_ru: string;
|
||||||
|
description_uz: string;
|
||||||
|
image: string;
|
||||||
|
link: string;
|
||||||
|
position: "banner1" | "banner2" | "banner3" | "banner4";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AllGetBanner {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
links: {
|
||||||
|
previous: string;
|
||||||
|
next: string;
|
||||||
|
};
|
||||||
|
total_items: number;
|
||||||
|
total_pages: number;
|
||||||
|
page_size: number;
|
||||||
|
current_page: number;
|
||||||
|
results: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
image: string;
|
||||||
|
link: string;
|
||||||
|
position: "banner1" | "banner2" | "banner3" | "banner4";
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}
|
||||||
583
src/pages/site-banner/ui/Banner.tsx
Normal file
583
src/pages/site-banner/ui/Banner.tsx
Normal file
@@ -0,0 +1,583 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
bannerDelete,
|
||||||
|
createBanner,
|
||||||
|
getBanner,
|
||||||
|
getBannerDetail,
|
||||||
|
updateBanner,
|
||||||
|
} from "@/pages/site-banner/lib/api";
|
||||||
|
import TicketsImagesModel from "@/pages/tours/ui/TicketsImagesModel";
|
||||||
|
import { Badge } from "@/shared/ui/badge";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/shared/ui/form";
|
||||||
|
import { Input } from "@/shared/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/shared/ui/table";
|
||||||
|
import { Textarea } from "@/shared/ui/textarea";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Edit2,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const fileSchema = z.union([
|
||||||
|
z.instanceof(File, { message: "Rasm faylini yuklang" }),
|
||||||
|
z.string().min(1, { message: "Rasm faylini yuklang" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const bannerSchema = z.object({
|
||||||
|
title: z
|
||||||
|
.string()
|
||||||
|
.min(3, "Sarlavha kamida 3 ta belgidan iborat bo‘lishi kerak"),
|
||||||
|
title_ru: z
|
||||||
|
.string()
|
||||||
|
.min(3, "Sarlavha kamida 3 ta belgidan iborat bo‘lishi kerak"),
|
||||||
|
description: z
|
||||||
|
.string()
|
||||||
|
.min(5, "Tavsif kamida 5 ta belgidan iborat bo‘lishi kerak"),
|
||||||
|
description_ru: z
|
||||||
|
.string()
|
||||||
|
.min(5, "Tavsif kamida 5 ta belgidan iborat bo‘lishi kerak"),
|
||||||
|
image: fileSchema,
|
||||||
|
link: z.string().url("Yaroqli havola URL manzili kiriting"),
|
||||||
|
position: z.string().min(1, "Pozitsiyani tanlang"),
|
||||||
|
});
|
||||||
|
|
||||||
|
type BannerFormData = z.infer<typeof bannerSchema>;
|
||||||
|
|
||||||
|
const positions = [
|
||||||
|
{ value: "banner1", label: "Asosiy" },
|
||||||
|
{ value: "banner2", label: "Kun taklifi" },
|
||||||
|
{ value: "banner3", label: "Mashhur yo‘nalishlar" },
|
||||||
|
{ value: "banner4", label: "Reytingi baland turlar" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SiteBannerAdmin = () => {
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const {
|
||||||
|
data: banner,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["all_banner", currentPage],
|
||||||
|
queryFn: () => getBanner({ page: currentPage, page_size: 10 }),
|
||||||
|
select(data) {
|
||||||
|
return data.data.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [editingBanner, setEditingBanner] = useState<number | null>(null);
|
||||||
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const form = useForm<BannerFormData>({
|
||||||
|
resolver: zodResolver(bannerSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
image: "",
|
||||||
|
link: "",
|
||||||
|
position: "banner1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: bannerDetail } = useQuery({
|
||||||
|
queryKey: ["detail_banner", editingBanner],
|
||||||
|
queryFn: () => getBannerDetail(editingBanner!),
|
||||||
|
select(data) {
|
||||||
|
return data.data.data;
|
||||||
|
},
|
||||||
|
enabled: !!editingBanner,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingBanner && bannerDetail) {
|
||||||
|
form.setValue("title", bannerDetail.title_uz);
|
||||||
|
form.setValue("title_ru", bannerDetail.title_ru);
|
||||||
|
form.setValue("description", bannerDetail.description_uz);
|
||||||
|
form.setValue("description_ru", bannerDetail.description_ru);
|
||||||
|
form.setValue("image", bannerDetail.image);
|
||||||
|
form.setValue("link", bannerDetail.link);
|
||||||
|
form.setValue("position", bannerDetail.position);
|
||||||
|
}
|
||||||
|
}, [bannerDetail, editingBanner]);
|
||||||
|
|
||||||
|
const { mutate: create, isPending } = useMutation({
|
||||||
|
mutationFn: (body: FormData) => createBanner(body),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({ queryKey: ["all_banner"] });
|
||||||
|
queryClient.refetchQueries({ queryKey: ["detail_banner"] });
|
||||||
|
setOpen(false);
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
richColors: true,
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: update, isPending: updatePending } = useMutation({
|
||||||
|
mutationFn: ({ body, id }: { id: number; body: FormData }) =>
|
||||||
|
updateBanner({ body, id }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({ queryKey: ["all_banner"] });
|
||||||
|
queryClient.refetchQueries({ queryKey: ["detail_banner"] });
|
||||||
|
setOpen(false);
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
richColors: true,
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: deletBanner, isPending: deletePending } = useMutation({
|
||||||
|
mutationFn: ({ id }: { id: number }) => bannerDelete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({ queryKey: ["all_banner"] });
|
||||||
|
queryClient.refetchQueries({ queryKey: ["detail_banner"] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("Xatolik yuz berdi"), {
|
||||||
|
richColors: true,
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleOpen = (banner: number) => {
|
||||||
|
setOpen(true);
|
||||||
|
setEditingBanner(banner);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = (values: BannerFormData) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("title", values.title);
|
||||||
|
formData.append("title_ru", values.title_ru);
|
||||||
|
formData.append("description", values.description);
|
||||||
|
formData.append("description_ru", values.description_ru);
|
||||||
|
if (values.image instanceof File) {
|
||||||
|
formData.append("image", values.image);
|
||||||
|
}
|
||||||
|
formData.append("link", values.link);
|
||||||
|
formData.append("position", values.position);
|
||||||
|
|
||||||
|
if (editingBanner) {
|
||||||
|
update({
|
||||||
|
body: formData,
|
||||||
|
id: editingBanner,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
create(formData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: number) => {
|
||||||
|
deletBanner({ id });
|
||||||
|
setDeleteId(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 text-white gap-4 w-full">
|
||||||
|
<Loader2 className="w-10 h-10 animate-spin text-cyan-400" />
|
||||||
|
<p className="text-slate-400">{t("Ma'lumotlar yuklanmoqda...")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 w-full text-center text-white gap-4">
|
||||||
|
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||||||
|
<p className="text-lg">
|
||||||
|
{t("Ma'lumotlarni yuklashda xatolik yuz berdi.")}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="bg-gradient-to-r from-blue-600 to-cyan-600 text-white rounded-lg px-5 py-2 hover:opacity-90"
|
||||||
|
>
|
||||||
|
{t("Qayta urinish")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen w-full bg-gray-900 text-white p-8">
|
||||||
|
<div className="max-w-full mx-auto">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-white">
|
||||||
|
{t("Sayt Bannerlari")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-2">{t("Bannerlarni boshqarish")}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="gap-2 bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(true);
|
||||||
|
setEditingBanner(null);
|
||||||
|
form.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={18} /> {t("Qo'shish")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-800 rounded-lg shadow-md border border-gray-700">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="border-b border-gray-700 bg-gray-800/60">
|
||||||
|
<TableHead className="text-gray-300 font-medium">ID</TableHead>
|
||||||
|
<TableHead className="text-gray-300 font-medium">
|
||||||
|
{t("Rasm")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-gray-300 font-medium">
|
||||||
|
{t("Sarlavha")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-gray-300 font-medium">
|
||||||
|
{t("Tavsif")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-gray-300 font-medium">
|
||||||
|
{t("Joylashuvi")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-gray-300 font-medium text-right">
|
||||||
|
{t("Amallar")}
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{banner && banner.results.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={6}
|
||||||
|
className="py-10 text-center text-gray-400"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3">
|
||||||
|
<AlertTriangle className="w-8 h-8 text-yellow-500" />
|
||||||
|
<p className="text-lg">
|
||||||
|
{t("Hozircha bannerlar mavjud emas")}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white mt-2"
|
||||||
|
>
|
||||||
|
<Plus size={16} className="mr-2" />
|
||||||
|
{t("Qo'shish")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
banner &&
|
||||||
|
banner.results.map((item) => (
|
||||||
|
<TableRow
|
||||||
|
key={item.id}
|
||||||
|
className="border-b border-gray-700 hover:bg-gray-700/30 transition-colors"
|
||||||
|
>
|
||||||
|
<TableCell className="text-gray-300">{item.id}</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<img
|
||||||
|
src={item.image}
|
||||||
|
alt={item.title}
|
||||||
|
className="w-24 h-16 object-cover rounded-md border border-gray-600"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="font-medium text-white">
|
||||||
|
{item.title}
|
||||||
|
<div className="text-blue-400 text-sm truncate max-w-[180px]">
|
||||||
|
<a href={item.link} target="_blank" rel="noreferrer">
|
||||||
|
{item.link}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="text-gray-300 truncate max-w-xs">
|
||||||
|
{item.description}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="bg-blue-900 text-blue-300"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
positions.find((p) => p.value === item.position)
|
||||||
|
?.label ?? "",
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="text-right flex justify-end items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="border-gray-600 text-blue-400 hover:text-blue-200"
|
||||||
|
onClick={() => handleOpen(item.id)}
|
||||||
|
>
|
||||||
|
<Edit2 size={16} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDelete(item.id)}
|
||||||
|
>
|
||||||
|
{deleteId === item.id && deletePending ? (
|
||||||
|
<Loader2 className="animate-spin" size={16} />
|
||||||
|
) : (
|
||||||
|
<Trash2 size={16} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 mt-5">
|
||||||
|
<button
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
|
||||||
|
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all hover:border-slate-500"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{[...Array(banner?.total_pages)].map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setCurrentPage(i + 1)}
|
||||||
|
className={`px-4 py-2 rounded-lg border transition-all font-medium ${
|
||||||
|
currentPage === i + 1
|
||||||
|
? "bg-gradient-to-r from-blue-600 to-cyan-600 border-blue-500 text-white shadow-lg shadow-cyan-500/50"
|
||||||
|
: "border-slate-600 text-slate-300 hover:bg-slate-700/50 hover:border-slate-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
disabled={currentPage === banner?.total_pages}
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage((p) =>
|
||||||
|
Math.min(p + 1, banner ? banner?.total_pages : 0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all hover:border-slate-500"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-scroll bg-gray-800 border border-gray-700 text-white">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingBanner
|
||||||
|
? t("Bannerni tahrirlash")
|
||||||
|
: t("Yangi banner qo'shish")}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("Sarlavha")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="bg-gray-700 border-gray-600 text-white"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="title_ru"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("Sarlavha")} (ru)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="bg-gray-700 border-gray-600 text-white"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("Tavsif")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
className="bg-gray-700 border-gray-600 text-white"
|
||||||
|
rows={3}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description_ru"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("Tavsif")} (ru)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
className="bg-gray-700 border-gray-600 text-white"
|
||||||
|
rows={3}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TicketsImagesModel
|
||||||
|
form={form}
|
||||||
|
name="image"
|
||||||
|
multiple={false}
|
||||||
|
label={t("Banner")}
|
||||||
|
imageUrl={bannerDetail?.image}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="link"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("Havola URL")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="bg-gray-700 border-gray-600 text-white"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="position"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("Joylashuvi")}</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
value={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger className="w-full bg-gray-700 border-gray-600 text-white">
|
||||||
|
<SelectValue placeholder="Pozitsiyani tanlang" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent className="bg-gray-800 text-white border-gray-700">
|
||||||
|
{positions.map((pos) => (
|
||||||
|
<SelectItem key={pos.value} value={pos.value}>
|
||||||
|
{t(pos.label)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
|
>
|
||||||
|
{isPending || updatePending ? (
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>{editingBanner ? "Saqlash" : "Qo‘shish"}</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SiteBannerAdmin;
|
||||||
112
src/pages/site-banner/ui/BannerCarousel.tsx
Normal file
112
src/pages/site-banner/ui/BannerCarousel.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { getBanner } from "@/pages/site-banner/lib/api";
|
||||||
|
import { Card } from "@/shared/ui/card";
|
||||||
|
import {
|
||||||
|
Carousel,
|
||||||
|
CarouselContent,
|
||||||
|
CarouselItem,
|
||||||
|
CarouselNext,
|
||||||
|
CarouselPrevious,
|
||||||
|
} from "@/shared/ui/carousel";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { AlertTriangle, Loader2, MoveRightIcon } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
const BannerCarousel = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// 🧠 Bannerlarni backenddan olish
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ["all_banner"],
|
||||||
|
queryFn: () => getBanner(),
|
||||||
|
select: (res) =>
|
||||||
|
res.data.data.results.filter((b) => b.position === "banner1"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const colors = ["#EDF5C7", "#F5DCC7"];
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<Loader2 className="animate-spin text-blue-500 w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isError)
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[400px] text-white">
|
||||||
|
<AlertTriangle className="text-red-500 w-8 h-8 mb-2" />
|
||||||
|
<p>{t("Ma'lumotlarni yuklashda xatolik yuz berdi.")}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="mt-3 bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg"
|
||||||
|
>
|
||||||
|
{t("Qayta urinish")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data || data.length === 0)
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center text-gray-400 min-h-[400px]">
|
||||||
|
{t("Hozircha bannerlar mavjud emas")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-10 max-lg:hidden custom-container">
|
||||||
|
<Carousel opts={{ loop: true, align: "start" }}>
|
||||||
|
<CarouselContent>
|
||||||
|
{data.map((banner, index) => (
|
||||||
|
<CarouselItem
|
||||||
|
key={banner.id}
|
||||||
|
className="basis-full md:basis-[80%] shrink-0"
|
||||||
|
>
|
||||||
|
<div className="h-[500px]">
|
||||||
|
<Card className="h-full !rounded-[50px] flex border-none items-center justify-start relative overflow-hidden">
|
||||||
|
{/* <BannerCircle
|
||||||
|
color={colors[index % colors.length]}
|
||||||
|
className="w-[60%] h-full absolute z-10"
|
||||||
|
/> */}
|
||||||
|
|
||||||
|
{/* Matn qismi */}
|
||||||
|
<div className="flex flex-col gap-6 w-96 z-20 absolute left-14 top-1/2 -translate-y-1/2">
|
||||||
|
<p className="text-4xl font-semibold text-[#232325]">
|
||||||
|
{banner.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-[#212122] font-medium">
|
||||||
|
{banner.description}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to={banner.link || "#"}
|
||||||
|
className="bg-white text-[#212122] font-semibold flex gap-4 px-8 py-4 shadow-sm !rounded-4xl w-fit"
|
||||||
|
>
|
||||||
|
<p>{t("Batafsil")}</p>
|
||||||
|
<MoveRightIcon />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rasm qismi */}
|
||||||
|
<div className="absolute right-0 w-[50%] h-full">
|
||||||
|
<img
|
||||||
|
src={banner.image}
|
||||||
|
alt={banner.title}
|
||||||
|
className="object-cover w-full h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</CarouselItem>
|
||||||
|
))}
|
||||||
|
</CarouselContent>
|
||||||
|
|
||||||
|
<CarouselPrevious className="absolute left-2 top-1/2 -translate-y-1/2 bg-white/80 hover:bg-white p-2 size-10 rounded-full shadow z-10" />
|
||||||
|
<CarouselNext className="absolute right-2 top-1/2 -translate-y-1/2 bg-white/80 hover:bg-white p-2 rounded-full size-10 shadow z-10" />
|
||||||
|
</Carousel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BannerCarousel;
|
||||||
122
src/pages/site-page/lib/api.ts
Normal file
122
src/pages/site-page/lib/api.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import type {
|
||||||
|
GetAllHelpPage,
|
||||||
|
GetAllOfferta,
|
||||||
|
GetDetailHelpPage,
|
||||||
|
GetDetailOfferta,
|
||||||
|
} from "@/pages/site-page/lib/types";
|
||||||
|
import httpClient from "@/shared/config/api/httpClient";
|
||||||
|
import { HELP_PAGE, OFFERTA } from "@/shared/config/api/URLs";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
|
||||||
|
const createOfferta = async ({
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
body: {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
person_type: "individual" | "legal_entity";
|
||||||
|
is_active: boolean;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const res = await httpClient.post(OFFERTA, body);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateOfferta = async ({
|
||||||
|
body,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
body: {
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
person_type?: "individual" | "legal_entity";
|
||||||
|
is_active?: boolean;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const res = await httpClient.patch(`${OFFERTA}${id}/`, body);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAllOfferta = async (params: {
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}): Promise<AxiosResponse<GetAllOfferta>> => {
|
||||||
|
const res = await httpClient.get(OFFERTA, { params });
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOneOfferta = async (
|
||||||
|
id: number,
|
||||||
|
): Promise<AxiosResponse<GetDetailOfferta>> => {
|
||||||
|
const res = await httpClient.get(`${OFFERTA}${id}/`);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteOfferta = async (id: number) => {
|
||||||
|
const res = await httpClient.delete(`${OFFERTA}${id}/`);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createHelpPage = async ({
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
body: {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
page_type: "privacy_policy" | "user_agreement";
|
||||||
|
is_active: boolean;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const res = await httpClient.post(HELP_PAGE, body);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAllHelpPage = async (params: {
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}): Promise<AxiosResponse<GetAllHelpPage>> => {
|
||||||
|
const res = await httpClient.get(HELP_PAGE, { params });
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDetailHelpPage = async (
|
||||||
|
id: number,
|
||||||
|
): Promise<AxiosResponse<GetDetailHelpPage>> => {
|
||||||
|
const res = await httpClient.get(`${HELP_PAGE}${id}/`);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateHelpPage = async ({
|
||||||
|
body,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
body: {
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
page_type?: "privacy_policy" | "user_agreement";
|
||||||
|
is_active?: boolean;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const res = await httpClient.patch(`${HELP_PAGE}${id}/`, body);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteHelpPage = async (id: number) => {
|
||||||
|
const res = await httpClient.delete(`${HELP_PAGE}${id}/`);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
createHelpPage,
|
||||||
|
createOfferta,
|
||||||
|
deleteHelpPage,
|
||||||
|
deleteOfferta,
|
||||||
|
getAllHelpPage,
|
||||||
|
getAllOfferta,
|
||||||
|
getDetailHelpPage,
|
||||||
|
getOneOfferta,
|
||||||
|
updateHelpPage,
|
||||||
|
updateOfferta,
|
||||||
|
};
|
||||||
63
src/pages/site-page/lib/types.ts
Normal file
63
src/pages/site-page/lib/types.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
export interface GetAllOfferta {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
links: {
|
||||||
|
previous: string;
|
||||||
|
next: string;
|
||||||
|
};
|
||||||
|
total_items: number;
|
||||||
|
total_pages: number;
|
||||||
|
page_size: number;
|
||||||
|
current_page: number;
|
||||||
|
results: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
person_type: "individual" | "legal_entity";
|
||||||
|
is_active: boolean;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetDetailOfferta {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
person_type: "individual" | "legal_entity";
|
||||||
|
is_active: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetAllHelpPage {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
links: {
|
||||||
|
previous: string;
|
||||||
|
next: string;
|
||||||
|
};
|
||||||
|
total_items: number;
|
||||||
|
total_pages: number;
|
||||||
|
page_size: number;
|
||||||
|
current_page: number;
|
||||||
|
results: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
page_type: "privacy_policy" | "user_agreement";
|
||||||
|
is_active: boolean;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetDetailHelpPage {
|
||||||
|
status: true;
|
||||||
|
data: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
page_type: "privacy_policy" | "user_agreement";
|
||||||
|
is_active: true;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,10 @@
|
|||||||
|
import {
|
||||||
|
createHelpPage,
|
||||||
|
deleteHelpPage,
|
||||||
|
getAllHelpPage,
|
||||||
|
getDetailHelpPage,
|
||||||
|
updateHelpPage,
|
||||||
|
} from "@/pages/site-page/lib/api";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { 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 qo‘llanmasi" | "Maxfiylik siyosati";
|
|
||||||
content: string;
|
|
||||||
active: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const FAKE_DATA: Offer[] = [
|
|
||||||
{
|
|
||||||
id: "of-1",
|
|
||||||
title: "Ommaviy oferta - Standart shartlar",
|
|
||||||
audience: "Foydalanuvchi qo‘llanmasi",
|
|
||||||
content:
|
|
||||||
"Bu hujjat kompaniya va xizmatlardan foydalanish bo'yicha umumiy shartlarni o'z ichiga oladi.",
|
|
||||||
active: true,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "of-2",
|
|
||||||
title: "Foydalanuvchi qo‘llanmasi uchun oferta",
|
|
||||||
audience: "Foydalanuvchi qo‘llanmasi",
|
|
||||||
content: "Foydalanuvchi qo‘llanmasi uchun maxsus shartlar va kafolatlar.",
|
|
||||||
active: false,
|
|
||||||
createdAt: new Date(Date.now() - 86400000).toISOString(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const STORAGE_KEY = "ommaviy_oferta_v1";
|
|
||||||
|
|
||||||
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 qo‘llanmasi",
|
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 qo‘llanmasi",
|
|
||||||
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,
|
||||||
|
},
|
||||||
|
id: editing,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
const newItem: Offer = {
|
create({
|
||||||
id: `of-${Date.now()}`,
|
body: {
|
||||||
title: (form.title || "Untitled").trim(),
|
content: form.content,
|
||||||
audience: (form.audience as Offer["audience"]) || "Barcha",
|
is_active: form.is_active,
|
||||||
content: (form.content || "").trim(),
|
page_type: form.page_type,
|
||||||
active: form.active ?? true,
|
title: form.title,
|
||||||
createdAt: new Date().toISOString(),
|
},
|
||||||
};
|
});
|
||||||
setItems((prev) => [newItem, ...prev]);
|
|
||||||
resetForm();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEdit(item: Offer) {
|
function startEdit(item: number) {
|
||||||
setEditing(item);
|
setEditing(item);
|
||||||
setForm({ ...item });
|
|
||||||
setErrors({});
|
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeItem(id: string) {
|
function removeItem(id: number) {
|
||||||
setItems((prev) => prev.filter((p) => p.id !== id));
|
deleteHelp(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleActive(id: string) {
|
function toggleActive(id: number, currentStatus: boolean) {
|
||||||
setItems((prev) =>
|
update({
|
||||||
prev.map((p) => (p.id === id ? { ...p, active: !p.active } : p)),
|
id: id,
|
||||||
);
|
body: {
|
||||||
|
is_active: !currentStatus,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = items.filter((it) => {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
if (!q) return true;
|
|
||||||
return (
|
|
||||||
it.title.toLowerCase().includes(q) ||
|
|
||||||
it.content.toLowerCase().includes(q) ||
|
|
||||||
it.audience.toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
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 qo‘llanmasi">
|
<SelectItem value="user_agreement">
|
||||||
Foydalanuvchi qo‘llanmasi
|
{t("Qo‘llanma")}
|
||||||
</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("O‘chirishni tasdiqlash")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Haqiqatan ham bu ofertani o'chirmoqchimisiz? Bu amalni
|
{t(
|
||||||
bekor qilib bo'lmaydi.
|
"Haqiqatan ham bu yordam sahifasini 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>
|
||||||
|
|||||||
@@ -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 {
|
} else if (editing) {
|
||||||
const newItem: Offer = {
|
update({
|
||||||
id: `of-${Date.now()}`,
|
body: {
|
||||||
title: (form.title || "Untitled").trim(),
|
content: form.content,
|
||||||
audience: (form.audience as Offer["audience"]) || "Barcha",
|
is_active: form.active,
|
||||||
content: (form.content || "").trim(),
|
person_type:
|
||||||
active: form.active ?? true,
|
form.audience === "Jismoniy shaxslar"
|
||||||
createdAt: new Date().toISOString(),
|
? "individual"
|
||||||
};
|
: "legal_entity",
|
||||||
setItems((prev) => [newItem, ...prev]);
|
title: form.title,
|
||||||
resetForm();
|
},
|
||||||
|
id: editing,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEdit(item: Offer) {
|
function startEdit(item: number) {
|
||||||
setEditing(item);
|
setEditing(item);
|
||||||
setForm({ ...item });
|
|
||||||
setErrors({});
|
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeItem(id: string) {
|
function removeItem(id: number) {
|
||||||
setItems((prev) => prev.filter((p) => p.id !== id));
|
removeOfferta({ id });
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleActive(id: string) {
|
function toggleActive(id: number, currentStatus: boolean) {
|
||||||
setItems((prev) =>
|
update({
|
||||||
prev.map((p) => (p.id === id ? { ...p, active: !p.active } : p)),
|
id: id,
|
||||||
);
|
body: {
|
||||||
|
is_active: !currentStatus,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = items.filter((it) => {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
if (!q) return true;
|
|
||||||
return (
|
|
||||||
it.title.toLowerCase().includes(q) ||
|
|
||||||
it.content.toLowerCase().includes(q) ||
|
|
||||||
it.audience.toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
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">{t("Kimlar uchun")}</label>
|
||||||
<label className="text-sm font-medium">Kimlar uchun</label>
|
<Select
|
||||||
<Select
|
value={form.audience || t("Barcha")}
|
||||||
value={form.audience || "Barcha"}
|
onValueChange={(value) =>
|
||||||
onValueChange={(value) =>
|
setForm((s) => ({ ...s, audience: value }))
|
||||||
setForm((s) => ({
|
}
|
||||||
...s,
|
>
|
||||||
audience: value as Offer["audience"],
|
<SelectTrigger className="mt-1 w-full !h-12">
|
||||||
}))
|
<SelectValue />
|
||||||
}
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
<SelectTrigger className="mt-1 w-full !h-12">
|
<SelectItem value="Jismoniy shaxslar">
|
||||||
<SelectValue />
|
{t("Jismoniy shaxslar uchun")}
|
||||||
</SelectTrigger>
|
</SelectItem>
|
||||||
<SelectContent>
|
<SelectItem value="Yuridik shaxslar">
|
||||||
<SelectItem value="Barcha">Barcha</SelectItem>
|
{t("Yuridik shaxslar uchun")}
|
||||||
<SelectItem value="Jismoniy shaxslar">
|
</SelectItem>
|
||||||
Jismoniy shaxslar uchun
|
</SelectContent>
|
||||||
</SelectItem>
|
</Select>
|
||||||
<SelectItem value="Yuridik shaxslar">
|
{errors.audience && (
|
||||||
Yuridik shaxslar uchun
|
<p className="text-destructive text-sm mt-1">
|
||||||
</SelectItem>
|
{t(errors.audience)}
|
||||||
</SelectContent>
|
</p>
|
||||||
</Select>
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-end">
|
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
|
||||||
<Checkbox
|
|
||||||
checked={!!form.active}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setForm((s) => ({ ...s, active: checked ? true : false }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span>Faol</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
64
src/pages/support/lib/api.ts
Normal file
64
src/pages/support/lib/api.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import type {
|
||||||
|
GetSupportAgency,
|
||||||
|
GetSupportUser,
|
||||||
|
} from "@/pages/support/lib/types";
|
||||||
|
import httpClient from "@/shared/config/api/httpClient";
|
||||||
|
import { SUPPORT_AGENCY, SUPPORT_USER } from "@/shared/config/api/URLs";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
|
||||||
|
const getSupportUser = async (params: {
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
status: "pending" | "done" | "failed" | "";
|
||||||
|
}): Promise<AxiosResponse<GetSupportUser>> => {
|
||||||
|
const res = await httpClient.get(SUPPORT_USER, { params });
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSupportUser = async ({
|
||||||
|
body,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
body: {
|
||||||
|
name: string;
|
||||||
|
phone_number: string;
|
||||||
|
travel_agency: number | null;
|
||||||
|
status: "pending" | "done" | "failed";
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const res = await httpClient.patch(`${SUPPORT_USER}${id}/`, body);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSupportUser = async ({ id }: { id: number }) => {
|
||||||
|
const res = await httpClient.delete(`${SUPPORT_USER}${id}/`);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
//support for agency
|
||||||
|
|
||||||
|
const getSupportAgency = async (params: {
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
search: string;
|
||||||
|
status: "pending" | "approved" | "cancelled" | "";
|
||||||
|
}): Promise<AxiosResponse<GetSupportAgency>> => {
|
||||||
|
const res = await httpClient.get(SUPPORT_AGENCY, { params });
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSupportAgencyDetail = async (
|
||||||
|
id: number,
|
||||||
|
): Promise<AxiosResponse<any>> => {
|
||||||
|
const res = await httpClient.get(`${SUPPORT_AGENCY}${id}/`);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
deleteSupportUser,
|
||||||
|
getSupportAgency,
|
||||||
|
getSupportAgencyDetail,
|
||||||
|
getSupportUser,
|
||||||
|
updateSupportUser,
|
||||||
|
};
|
||||||
80
src/pages/support/lib/types.ts
Normal file
80
src/pages/support/lib/types.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
export interface GetSupportUser {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
links: {
|
||||||
|
previous: null | string;
|
||||||
|
next: null | string;
|
||||||
|
};
|
||||||
|
total_items: number;
|
||||||
|
total_pages: number;
|
||||||
|
page_size: number;
|
||||||
|
current_page: number;
|
||||||
|
results: GetSupportUserRes[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetSupportUserRes {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
phone_number: string;
|
||||||
|
travel_agency: null | number;
|
||||||
|
status: "pending" | "done" | "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetSupportAgency {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
links: {
|
||||||
|
previous: null | string;
|
||||||
|
next: null | string;
|
||||||
|
};
|
||||||
|
total_items: number;
|
||||||
|
total_pages: number;
|
||||||
|
page_size: number;
|
||||||
|
current_page: number;
|
||||||
|
results: GetSupportAgencyRes[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetSupportAgencyRes {
|
||||||
|
id: number;
|
||||||
|
status: "pending" | "approved" | "cancelled";
|
||||||
|
name: string;
|
||||||
|
addres: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
web_site: string;
|
||||||
|
travel_agency_documents: [
|
||||||
|
{
|
||||||
|
file: string;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetSupportAgencyDetail {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
links: {
|
||||||
|
previous: null | string;
|
||||||
|
next: null | string;
|
||||||
|
};
|
||||||
|
total_items: number;
|
||||||
|
total_pages: number;
|
||||||
|
page_size: number;
|
||||||
|
current_page: number;
|
||||||
|
results: {
|
||||||
|
id: number;
|
||||||
|
status: "pending" | "approved" | "cancelled";
|
||||||
|
name: string;
|
||||||
|
addres: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
web_site: string;
|
||||||
|
travel_agency_documents: [
|
||||||
|
{
|
||||||
|
file: string;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { XIcon } from "lucide-react";
|
import { 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,110 +49,118 @@ 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 &&
|
||||||
<div
|
data.data.data.results.map((r) => (
|
||||||
key={r.id}
|
<div
|
||||||
className="border rounded-lg p-4 shadow-sm hover:shadow-md transition bg-gray-800 text-white"
|
key={r.id}
|
||||||
>
|
className="border rounded-lg p-4 shadow-sm hover:shadow-md transition bg-gray-800 text-white"
|
||||||
<div className="flex items-start justify-between">
|
>
|
||||||
<div>
|
<div className="flex items-start justify-between">
|
||||||
<h3 className="text-lg font-medium">{r.name}</h3>
|
<div>
|
||||||
<p className="text-md">{r.address}</p>
|
<h3 className="text-lg font-medium">{r.name}</h3>
|
||||||
|
<p className="text-md">{r.addres}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-md">{formatPhone(r.phone)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-md">{r.phone}</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>
|
||||||
|
</div>
|
||||||
|
{r.web_site && (
|
||||||
|
<div>
|
||||||
|
<strong>{t("Veb-sayt")}:</strong>{" "}
|
||||||
|
<a
|
||||||
|
href={r.web_site}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-white hover:underline"
|
||||||
|
>
|
||||||
|
{r.web_site.replace(/^https?:\/\//, "")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelected(r)}
|
||||||
|
className="px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition"
|
||||||
|
>
|
||||||
|
{t("Tafsilotlar")}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
to={`mailto:${r.email}`}
|
||||||
|
className="px-3 py-1 rounded border text-sm transition"
|
||||||
|
>
|
||||||
|
{t("Javob yozish")}
|
||||||
</Link>
|
</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 && (
|
|
||||||
<div>
|
|
||||||
<strong>Website:</strong>{" "}
|
|
||||||
<a
|
|
||||||
href={r.web_site}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-white hover:underline"
|
|
||||||
>
|
|
||||||
{r.web_site.replace(/^https?:\/\//, "")}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
<div className="mt-4 flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setSelected(r)}
|
|
||||||
className="px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition"
|
|
||||||
>
|
|
||||||
Tafsilotlar
|
|
||||||
</button>
|
|
||||||
<Link
|
|
||||||
to={`mailto:${r.email}`}
|
|
||||||
className="px-3 py-1 rounded border text-sm transition"
|
|
||||||
>
|
|
||||||
Javob yozish
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
@@ -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: "To‘lov muvaffaqiyatli o‘tmadi, yordam bera olasizmi?",
|
|
||||||
status: "Resolved",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: "Jamshid Abdullayev",
|
|
||||||
phone: "+998 93 555 22 11",
|
|
||||||
message: "Sayohat sanasini o‘zgartirishni istayman.",
|
|
||||||
status: "Pending",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
name: "Jamshid Abdullayev",
|
|
||||||
phone: "+998 93 555 22 11",
|
|
||||||
message: "Sayohat sanasini o‘zgartirishni istayman.",
|
|
||||||
status: "Pending",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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 so‘rovlari
|
{t("Yordam so'rovlari")}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="grid gap-5 sm:grid-cols-3 lg:grid-cols-3">
|
{/* Status Tabs */}
|
||||||
{requests.map((req) => (
|
<div className="flex gap-3 mb-4">
|
||||||
<Card
|
{[
|
||||||
key={req.id}
|
{ label: t("Barchasi"), value: "" },
|
||||||
className="bg-gray-800/70 border border-gray-700 shadow-md hover:shadow-lg hover:bg-gray-800 transition-all duration-200"
|
{ label: t("Kutilmoqda"), value: "pending" },
|
||||||
|
{ label: t("done"), value: "done" },
|
||||||
|
].map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.value}
|
||||||
|
className={`px-4 py-2 rounded-lg font-medium text-sm transition-colors ${
|
||||||
|
filterStatus === tab.value
|
||||||
|
? "bg-blue-600 text-white"
|
||||||
|
: "bg-gray-700 text-gray-300 hover:bg-gray-600"
|
||||||
|
}`}
|
||||||
|
onClick={() => setFilterStatus(tab.value as any)}
|
||||||
>
|
>
|
||||||
<CardHeader className="pb-2">
|
{tab.label}
|
||||||
<CardTitle className="flex items-center justify-between">
|
</button>
|
||||||
<span className="flex items-center gap-2 text-lg font-semibold text-white">
|
))}
|
||||||
<User className="w-5 h-5 text-blue-400" />
|
</div>
|
||||||
{req.name}
|
|
||||||
</span>
|
{/* Cards */}
|
||||||
|
<div className="grid gap-5 sm:grid-cols-3 lg:grid-cols-3">
|
||||||
|
{data && data.data.data.results.length === 0 ? (
|
||||||
|
<div className="col-span-full flex flex-col items-center justify-center min-h-[50vh] w-full text-center text-white gap-4">
|
||||||
|
<p className="text-lg">{t("Natija topilmadi")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
data?.data.data.results.map((req) => (
|
||||||
|
<Card
|
||||||
|
key={req.id}
|
||||||
|
className="bg-gray-800/70 border border-gray-700 shadow-md hover:shadow-lg hover:bg-gray-800 transition-all duration-200 justify-between"
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-2 flex justify-between items-center">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-white">
|
||||||
|
<User className="w-5 h-5 text-blue-400" />
|
||||||
|
{req.name}
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Badge
|
<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 ? (
|
||||||
<Phone className="w-4 h-4 text-gray-400" />
|
<span className="text-md text-gray-400">
|
||||||
{req.phone}
|
{t("Agentlikka tegishli")}
|
||||||
</div>
|
</span>
|
||||||
|
) : (
|
||||||
<div className="flex items-start gap-2 text-gray-300">
|
<span className="text-md text-gray-400">
|
||||||
<MessageCircle className="w-4 h-4 text-gray-400 mt-1" />
|
{t("Sayt bo'yicha")}
|
||||||
<p className="text-sm leading-relaxed">{req.message}</p>
|
</span>
|
||||||
</div>
|
)}
|
||||||
|
<div className="flex items-center gap-2 mt-2 text-sm text-gray-400">
|
||||||
<Button
|
<Phone className="w-4 h-4 text-gray-400" />
|
||||||
variant="outline"
|
{formatPhone(req.phone_number)}
|
||||||
size="sm"
|
</div>
|
||||||
className="mt-3 w-full border-gray-600 text-gray-200 hover:bg-gray-700 hover:text-white"
|
<div className="grid grid-cols-2 justify-end items-end gap-2">
|
||||||
onClick={() => setSelected(req)}
|
<Button
|
||||||
>
|
variant="outline"
|
||||||
Batafsil ko‘rish
|
size="sm"
|
||||||
</Button>
|
className="mt-3 w-full border-gray-600 text-gray-200 hover:bg-gray-700 hover:text-white"
|
||||||
</CardContent>
|
onClick={() => setSelected(req)}
|
||||||
</Card>
|
>
|
||||||
))}
|
{t("Batafsil ko'rish")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
onClick={() => setSelectedToDelete(req)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" /> {t("O'chirish")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
70
src/pages/tour-settings/lib/api.ts
Normal file
70
src/pages/tour-settings/lib/api.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import type {
|
||||||
|
GetDetailSiteSetting,
|
||||||
|
GetSiteSetting,
|
||||||
|
} from "@/pages/tour-settings/lib/types";
|
||||||
|
import httpClient from "@/shared/config/api/httpClient";
|
||||||
|
import { SITE_SETTING } from "@/shared/config/api/URLs";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
|
||||||
|
const createSiteSetting = async (body: {
|
||||||
|
address: string;
|
||||||
|
latitude: string;
|
||||||
|
longitude: string;
|
||||||
|
telegram: string;
|
||||||
|
instagram: string;
|
||||||
|
facebook: string;
|
||||||
|
twitter: string;
|
||||||
|
email: string;
|
||||||
|
main_phone: string;
|
||||||
|
other_phone: string;
|
||||||
|
}) => {
|
||||||
|
const res = await httpClient.post(SITE_SETTING, body);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSiteSetting = async ({
|
||||||
|
body,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
body: {
|
||||||
|
address: string;
|
||||||
|
latitude: string;
|
||||||
|
longitude: string;
|
||||||
|
telegram: string;
|
||||||
|
instagram: string;
|
||||||
|
facebook: string;
|
||||||
|
twitter: string;
|
||||||
|
email: string;
|
||||||
|
main_phone: string;
|
||||||
|
other_phone: string;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const res = await httpClient.patch(`${SITE_SETTING}${id}/`, body);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSiteSetting = async (): Promise<AxiosResponse<GetSiteSetting>> => {
|
||||||
|
const res = await httpClient.get(SITE_SETTING);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDetailSiteSetting = async (
|
||||||
|
id: number,
|
||||||
|
): Promise<AxiosResponse<GetDetailSiteSetting>> => {
|
||||||
|
const res = await httpClient.get(`${SITE_SETTING}${id}/`);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSiteSetting = async (id: number) => {
|
||||||
|
const res = await httpClient.delete(`${SITE_SETTING}${id}/`);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
createSiteSetting,
|
||||||
|
deleteSiteSetting,
|
||||||
|
getDetailSiteSetting,
|
||||||
|
getSiteSetting,
|
||||||
|
updateSiteSetting,
|
||||||
|
};
|
||||||
43
src/pages/tour-settings/lib/types.ts
Normal file
43
src/pages/tour-settings/lib/types.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
export interface GetSiteSetting {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
links: {
|
||||||
|
previous: string;
|
||||||
|
next: string;
|
||||||
|
};
|
||||||
|
total_items: number;
|
||||||
|
total_pages: number;
|
||||||
|
page_size: number;
|
||||||
|
current_page: number;
|
||||||
|
results: {
|
||||||
|
id: number;
|
||||||
|
address: string;
|
||||||
|
latitude: string;
|
||||||
|
longitude: string;
|
||||||
|
telegram: string;
|
||||||
|
instagram: string;
|
||||||
|
facebook: string;
|
||||||
|
twitter: string;
|
||||||
|
email: string;
|
||||||
|
main_phone: string;
|
||||||
|
other_phone: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetDetailSiteSetting {
|
||||||
|
status: boolean;
|
||||||
|
data: {
|
||||||
|
id: number;
|
||||||
|
address: string;
|
||||||
|
latitude: string;
|
||||||
|
longitude: string;
|
||||||
|
telegram: string;
|
||||||
|
instagram: string;
|
||||||
|
facebook: string;
|
||||||
|
twitter: string;
|
||||||
|
email: string;
|
||||||
|
main_phone: string;
|
||||||
|
other_phone: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
"use client";
|
"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">
|
||||||
<Card className="bg-gray-900">
|
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
|
||||||
<CardHeader>
|
<span className="ml-2 text-gray-400">{t("Yuklanmoqda...")}</span>
|
||||||
<CardTitle>Hozircha kontakt ma'lumotlari qo'shilmagan</CardTitle>
|
</div>
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Sayt uchun telegram, instagram, manzil, email va telefonni bu
|
|
||||||
yerda saqlang. Siz faqat bir marta qo'sha olasiz — keyin
|
|
||||||
tahrirlash mumkin.
|
|
||||||
</p>
|
|
||||||
<div className="mt-4">
|
|
||||||
<Button onClick={startAdd} className="flex items-center gap-2">
|
|
||||||
<Plus size={14} /> Qo'shish
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
) : (
|
||||||
<Card className="bg-gray-900">
|
<>
|
||||||
<CardHeader className="flex items-center justify-between">
|
{data && data?.total_items === 0 ? (
|
||||||
<CardTitle>Kontakt ma'lumotlari</CardTitle>
|
<Card className="bg-gray-900">
|
||||||
<div className="flex gap-2">
|
<CardHeader>
|
||||||
<Button
|
<CardTitle>
|
||||||
variant="outline"
|
{t("Hozircha kontakt ma'lumotlari qo'shilmagan")}
|
||||||
onClick={startEdit}
|
</CardTitle>
|
||||||
className="flex items-center gap-2"
|
</CardHeader>
|
||||||
>
|
<CardContent>
|
||||||
<Edit size={14} /> Tahrirlash
|
<p className="text-sm text-muted-foreground">
|
||||||
</Button>
|
{t(
|
||||||
<Button
|
"Sayt uchun telegram, instagram, manzil, email va telefonni bu yerda saqlang. Siz faqat bir marta qo'sha olasiz — keyin tahrirlash mumkin.",
|
||||||
variant="destructive"
|
)}
|
||||||
onClick={removeContact}
|
</p>
|
||||||
className="flex items-center gap-2"
|
<div className="mt-4">
|
||||||
>
|
<Button
|
||||||
<Trash size={14} /> O'chirish
|
onClick={startAdd}
|
||||||
</Button>
|
className="flex items-center gap-2"
|
||||||
</div>
|
>
|
||||||
</CardHeader>
|
<Plus size={14} /> {t("Qo'shish")}
|
||||||
<CardContent>
|
</Button>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-muted-foreground">Telegram</div>
|
|
||||||
<div className="text-sm">{contact.telegram || "—"}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground">Instagram</div>
|
|
||||||
<div className="text-sm">{contact.instagram || "—"}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground">Facebook</div>
|
|
||||||
<div className="text-sm">{contact.facebook || "—"}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground">LinkedIn</div>
|
|
||||||
<div className="text-sm">{contact.linkedin || "—"}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground">Twitter</div>
|
|
||||||
<div className="text-sm">{contact.twiter || "—"}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground">Manzil</div>
|
|
||||||
<div className="text-sm">{contact.address || "—"}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground">Email</div>
|
|
||||||
<div className="text-sm">{contact.email || "—"}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground">Telefonlar</div>
|
|
||||||
<div className="text-sm">
|
|
||||||
{contact.phonePrimary || "—"}
|
|
||||||
{contact.phoneSecondary ? ` • ${contact.phoneSecondary}` : ""}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</CardContent>
|
) : (
|
||||||
</Card>
|
<>
|
||||||
|
{data &&
|
||||||
|
data.results.map((e) => (
|
||||||
|
<Card className="bg-gray-900">
|
||||||
|
<CardHeader className="flex items-center justify-between">
|
||||||
|
<CardTitle>{t("Kontakt ma'lumotlari")}</CardTitle>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => startEdit(e.id)}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Edit size={14} /> {t("Tahrirlash")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => removeContact(e.id)}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Trash size={14} /> {t("O'chirish")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Telegram
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">{e.telegram || "—"}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Instagram
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">{e.instagram || "—"}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Facebook
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">{e.facebook || "—"}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Twitter
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">{e.twitter || "—"}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("Manzil")}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">{e.address || "—"}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Email
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">{e.email || "—"}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("Telefon raqam")}
|
||||||
|
</div>
|
||||||
|
<p>{e.main_phone}</p>
|
||||||
|
<p>{e.other_phone}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dialog */}
|
{/* Dialog */}
|
||||||
<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>
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 bo‘lishi mumkin emas." }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1, { message: "Kamida bitta pullik xizmat kiriting." }),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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("departureDateTime", {
|
form.setValue("price", tour.price ?? 0);
|
||||||
date: departureDate,
|
setDisplayPrice(formatPrice(tour.price ?? 0));
|
||||||
time: departureDate.toTimeString().slice(0, 8), // HH:MM:SS
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tourData.travel_time) {
|
form.setValue("passenger_count", tour.passenger_count ?? 1);
|
||||||
const travelDate = new Date(tourData.travel_time);
|
form.setValue("min_person", tour.min_person ?? 1);
|
||||||
form.setValue("travelDateTime", {
|
form.setValue("max_person", tour.max_person ?? 1);
|
||||||
date: travelDate,
|
|
||||||
time: travelDate.toTimeString().slice(0, 8),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Amenities
|
form.setValue("departure", tour.departure_uz ?? "");
|
||||||
if (tourData.ticket_amenities && tourData.ticket_amenities.length > 0) {
|
form.setValue("departure_ru", tour.departure_ru ?? "");
|
||||||
const amenities = tourData.ticket_amenities.map((item) => ({
|
form.setValue("destination", tour.destination_uz ?? "");
|
||||||
name: item.name,
|
form.setValue("destination_ru", tour.destination_ru ?? "");
|
||||||
name_ru: item.name_ru,
|
form.setValue("location_name", tour.location_name_uz ?? "");
|
||||||
icon_name: item.icon_name,
|
form.setValue("location_name_ru", tour.location_name_ru ?? "");
|
||||||
}));
|
|
||||||
form.setValue("amenities", amenities);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
form.setValue("hotel_info", tour.hotel_info_uz ?? "");
|
||||||
tourData.ticket_included_services &&
|
form.setValue("hotel_info_ru", tour.hotel_info_ru ?? "");
|
||||||
tourData.ticket_included_services.length > 0
|
form.setValue("hotel_meals_info", tour.hotel_meals_uz ?? "");
|
||||||
) {
|
form.setValue("hotel_meals_info_ru", tour.hotel_meals_ru ?? "");
|
||||||
const services = tourData.ticket_included_services.map((item) => ({
|
|
||||||
image: item.image,
|
|
||||||
title: item.title,
|
|
||||||
title_ru: item.title_ru,
|
|
||||||
description: item.desc_uz || item.desc,
|
|
||||||
desc_ru: item.desc || item.desc,
|
|
||||||
}));
|
|
||||||
form.setValue("hotel_services", services);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
form.setValue("languages", tour.languages ?? "");
|
||||||
tourData.ticket_hotel_meals &&
|
form.setValue("duration", tour.duration_days ?? 1);
|
||||||
tourData.ticket_hotel_meals.length > 0
|
form.setValue("visa_required", tour.visa_required ? "yes" : "no");
|
||||||
) {
|
form.setValue("badges", tour.badge ?? []);
|
||||||
const meals = tourData.ticket_hotel_meals.map((item) => ({
|
|
||||||
image: item.image,
|
|
||||||
title: item.name,
|
|
||||||
title_ru: item.name_ru,
|
|
||||||
description: item.desc,
|
|
||||||
desc_ru: item.desc_ru,
|
|
||||||
}));
|
|
||||||
form.setValue("hotel_meals", meals);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transport
|
// 🔹 Jo‘nash vaqti
|
||||||
if (tourData.transports && tourData.transports.length > 0) {
|
if (tour.departure_time) {
|
||||||
const transports = tourData.transports.map((item, index) => ({
|
const d = new Date(tour.departure_time);
|
||||||
transport: index + 1, // Agar transport ID bo'lsa, uni ishlatish kerak
|
form.setValue("departureDateTime", {
|
||||||
price: item.price,
|
date: d,
|
||||||
}));
|
time: d.toTimeString().slice(0, 8),
|
||||||
// const tariff = tourData.tar => ({
|
});
|
||||||
// transport: index + 1,
|
|
||||||
// price: item.price,
|
|
||||||
// }));
|
|
||||||
form.setValue("transport", transports);
|
|
||||||
// form.setValue("tarif", );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ticket itinerary
|
|
||||||
if (tourData.ticket_itinerary && tourData.ticket_itinerary.length > 0) {
|
|
||||||
const itinerary = tourData.ticket_itinerary.map((item) => ({
|
|
||||||
ticket_itinerary_image: [], // Image fayllarni alohida handle qilish kerak
|
|
||||||
title: item.title,
|
|
||||||
title_ru: item.title_ru,
|
|
||||||
duration: item.duration,
|
|
||||||
ticket_itinerary_destinations: [], // Agar destinations bo'lsa qo'shish kerak
|
|
||||||
}));
|
|
||||||
form.setValue("ticket_itinerary", itinerary);
|
|
||||||
}
|
|
||||||
|
|
||||||
form.setValue("banner", tourData.image_banner);
|
|
||||||
if (tourData.ticket_images && tourData.ticket_images.length > 0) {
|
|
||||||
const images = tourData.ticket_images.map((img) => img.image); // faqat linklarni olamiz
|
|
||||||
form.setValue("images", images);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [isEditMode, data, form]);
|
|
||||||
|
// 🔹 Qaytish vaqti
|
||||||
|
if (tour.travel_time) {
|
||||||
|
const d = new Date(tour.travel_time);
|
||||||
|
form.setValue("travelDateTime", {
|
||||||
|
date: d,
|
||||||
|
time: d.toTimeString().slice(0, 8),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔹 Qulayliklar (amenities)
|
||||||
|
form.setValue(
|
||||||
|
"amenities",
|
||||||
|
tour.ticket_amenities?.map((a) => ({
|
||||||
|
name: a.name ?? "",
|
||||||
|
name_ru: a.name_ru ?? "",
|
||||||
|
icon_name: a.icon_name ?? "",
|
||||||
|
})) ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 🔹 Xizmatlar (hotel_services)
|
||||||
|
form.setValue(
|
||||||
|
"hotel_services",
|
||||||
|
tour.ticket_included_services?.map((s) => ({
|
||||||
|
image: s.image ?? null,
|
||||||
|
title: s.title_uz ?? "",
|
||||||
|
title_ru: s.title_ru ?? "",
|
||||||
|
description: s.desc_uz ?? "",
|
||||||
|
desc_ru: s.desc_ru ?? "",
|
||||||
|
})) ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 🔹 Taomlar (hotel_meals)
|
||||||
|
form.setValue(
|
||||||
|
"hotel_meals",
|
||||||
|
tour.ticket_hotel_meals?.map((m) => ({
|
||||||
|
image: m.image ?? null,
|
||||||
|
title: m.name ?? "",
|
||||||
|
title_ru: m.name_ru ?? "",
|
||||||
|
description: m.desc ?? "",
|
||||||
|
desc_ru: m.desc_ru ?? "",
|
||||||
|
})) ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 🔹 Transport
|
||||||
|
const transports =
|
||||||
|
tour.transports?.map((t, i) => ({
|
||||||
|
transport: i + 1,
|
||||||
|
price: t.price ?? 0,
|
||||||
|
})) ?? [];
|
||||||
|
form.setValue("transport", transports);
|
||||||
|
setTransportPrices(transports.map((t) => formatPrice(t.price ?? 0))); // 👈 YANGI QO‘SHILGAN
|
||||||
|
|
||||||
|
// 🔹 Tarif
|
||||||
|
const tariffs =
|
||||||
|
tour.tariff?.map((t) => ({
|
||||||
|
tariff: t.tariff ?? 0,
|
||||||
|
price: t.price ?? 0,
|
||||||
|
})) ?? [];
|
||||||
|
form.setValue("tarif", tariffs);
|
||||||
|
setTarifDisplayPrice(tariffs.map((t) => formatPrice(t.price ?? 0)));
|
||||||
|
|
||||||
|
// 🔹 Yo‘nalishlar (ticket_itinerary)
|
||||||
|
form.setValue(
|
||||||
|
"ticket_itinerary",
|
||||||
|
tour.ticket_itinerary?.map((item) => ({
|
||||||
|
ticket_itinerary_image:
|
||||||
|
item.ticket_itinerary_image?.map((img) => ({
|
||||||
|
image: img.image,
|
||||||
|
})) ?? [],
|
||||||
|
title: item.title ?? "",
|
||||||
|
title_ru: item.title_ru ?? "",
|
||||||
|
duration: item.duration ?? 1,
|
||||||
|
ticket_itinerary_destinations:
|
||||||
|
item.ticket_itinerary_destinations?.map((d) => ({
|
||||||
|
name: d.name ?? "",
|
||||||
|
name_ru: d.name_ru ?? "",
|
||||||
|
})) ?? [],
|
||||||
|
})) ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 🔹 Banner va rasmlar
|
||||||
|
form.setValue("banner", tour.image_banner ?? null);
|
||||||
|
form.setValue("images", tour.ticket_images?.map((img) => img.image) ?? []);
|
||||||
|
|
||||||
|
// 🔹 Bepul xizmatlar (extra_service)
|
||||||
|
form.setValue(
|
||||||
|
"extra_service",
|
||||||
|
tour.extra_service?.map((s) => ({
|
||||||
|
name: s.name_uz ?? s.name ?? "",
|
||||||
|
name_ru: s.name_ru ?? "",
|
||||||
|
})) ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 🔹 Pullik xizmatlar (paid_extra_service)
|
||||||
|
form.setValue(
|
||||||
|
"paid_extra_service",
|
||||||
|
tour.paid_extra_service?.map((s) => ({
|
||||||
|
name: s.name_uz ?? s.name ?? "",
|
||||||
|
name_ru: s.name_ru ?? "",
|
||||||
|
price: s.price ?? 0,
|
||||||
|
})) ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 🔹 TicketStore uchun id
|
||||||
|
setId(tour.id);
|
||||||
|
}, [isEditMode, data, form, setId]);
|
||||||
|
|
||||||
const { watch, setValue } = form;
|
const { 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"));
|
||||||
formData.append("image_banner", value.banner);
|
if (value.banner instanceof File) {
|
||||||
|
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,43 +357,67 @@ const StepOne = ({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
value.hotel_services.forEach((e, i) => {
|
value.hotel_services.forEach((e, i) => {
|
||||||
formData.append(`ticket_included_services[${i}]image`, e.image);
|
if (e instanceof File) {
|
||||||
formData.append(`ticket_included_services[${i}]title`, e.title);
|
formData.append(`ticket_included_services[${i}]image`, e.image);
|
||||||
formData.append(`ticket_included_services[${i}]title_ru`, e.title_ru);
|
formData.append(`ticket_included_services[${i}]title`, e.title);
|
||||||
formData.append(`ticket_included_services[${i}]desc_ru`, e.desc_ru);
|
formData.append(`ticket_included_services[${i}]title_ru`, e.title_ru);
|
||||||
formData.append(`ticket_included_services[${i}]desc`, e.description);
|
formData.append(`ticket_included_services[${i}]desc_ru`, e.desc_ru);
|
||||||
|
formData.append(`ticket_included_services[${i}]desc`, e.description);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
value.ticket_itinerary.forEach((e, i) => {
|
value.ticket_itinerary.forEach((e, i) => {
|
||||||
formData.append(`ticket_itinerary[${i}]title`, e.title);
|
e.ticket_itinerary_image.forEach((l, f) => {
|
||||||
formData.append(`ticket_itinerary[${i}]title_ru`, e.title_ru);
|
if (e instanceof File) {
|
||||||
formData.append(`ticket_itinerary[${i}]duration`, String(e.duration));
|
formData.append(`ticket_itinerary[${i}]title`, e.title);
|
||||||
e.ticket_itinerary_image.forEach((e, f) => {
|
formData.append(`ticket_itinerary[${i}]title_ru`, e.title_ru);
|
||||||
formData.append(
|
formData.append(`ticket_itinerary[${i}]duration`, String(e.duration));
|
||||||
`ticket_itinerary[${i}]ticket_itinerary_image[${f}]image`,
|
formData.append(
|
||||||
e.image,
|
`ticket_itinerary[${i}]ticket_itinerary_image[${f}]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`,
|
||||||
String(e.name),
|
String(e.name),
|
||||||
);
|
);
|
||||||
formData.append(
|
formData.append(
|
||||||
`ticket_itinerary[${i}]ticket_itinerary_destinations[${f}]name_ru`,
|
`ticket_itinerary[${i}]ticket_itinerary_destinations[${f}]name_ru`,
|
||||||
String(e.name_ru),
|
String(e.name_ru),
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
value.hotel_meals.forEach((e, i) => {
|
value.hotel_meals.forEach((e, i) => {
|
||||||
formData.append(`ticket_hotel_meals[${i}]image`, e.image);
|
if (e instanceof File) {
|
||||||
formData.append(`ticket_hotel_meals[${i}]name`, e.title);
|
formData.append(`ticket_hotel_meals[${i}]image`, e.image);
|
||||||
formData.append(`ticket_hotel_meals[${i}]name_ru`, e.title_ru);
|
formData.append(`ticket_hotel_meals[${i}]name`, e.title);
|
||||||
formData.append(`ticket_hotel_meals[${i}]desc`, e.description);
|
formData.append(`ticket_hotel_meals[${i}]name_ru`, e.title_ru);
|
||||||
formData.append(`ticket_hotel_meals[${i}]desc_ru`, e.desc_ru);
|
formData.append(`ticket_hotel_meals[${i}]desc`, e.description);
|
||||||
|
formData.append(`ticket_hotel_meals[${i}]desc_ru`, e.desc_ru);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
create(formData);
|
value.extra_service.forEach((e, i) => {
|
||||||
|
formData.append(`extra_service[${i}]name`, e.name);
|
||||||
|
formData.append(`extra_service[${i}]name_ru`, e.name_ru);
|
||||||
|
});
|
||||||
|
value.paid_extra_service.forEach((e, i) => {
|
||||||
|
formData.append(`paid_extra_service[${i}]name`, e.name);
|
||||||
|
formData.append(`paid_extra_service[${i}]name_ru`, e.name_ru);
|
||||||
|
formData.append(`paid_extra_service[${i}]price`, String(e.price));
|
||||||
|
});
|
||||||
|
if (isEditMode && id) {
|
||||||
|
update({
|
||||||
|
body: formData,
|
||||||
|
id: Number(id),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
create(formData);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(form.formState.errors);
|
||||||
|
|
||||||
const { data: badge } = useQuery({
|
const { data: badge } = useQuery({
|
||||||
queryKey: ["all_badge"],
|
queryKey: ["all_badge"],
|
||||||
queryFn: () => hotelBadge({ page: 1, page_size: 10 }),
|
queryFn: () => hotelBadge({ page: 1, page_size: 10 }),
|
||||||
@@ -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("Yo‘q")}</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)} so‘m</strong>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const current = form.getValues("paid_extra_service");
|
||||||
|
form.setValue(
|
||||||
|
"paid_extra_service",
|
||||||
|
current.filter((_, i) => i !== idx),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="ml-1 text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Qo'shish formasi */}
|
||||||
|
<div className="flex gap-3 items-end flex-wrap">
|
||||||
|
<Input
|
||||||
|
id="paid_service_name"
|
||||||
|
placeholder={t("Xizmat nomi (UZ)")}
|
||||||
|
className="h-12 !text-md flex-1 min-w-[200px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="paid_service_name_ru"
|
||||||
|
placeholder={t("Xizmat nomi (RU)")}
|
||||||
|
className="h-12 !text-md flex-1 min-w-[200px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Narx maydoni */}
|
||||||
|
<Input
|
||||||
|
id="paid_service_price"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="1 500 000"
|
||||||
|
className="h-12 !text-md w-[150px]"
|
||||||
|
onInput={(e) => {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
const raw = input.value.replace(/\D/g, "");
|
||||||
|
input.value = raw
|
||||||
|
? Number(raw).toLocaleString("ru-RU")
|
||||||
|
: "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Qo'shish tugmasi */}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const nameInput = document.getElementById(
|
||||||
|
"paid_service_name",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const nameRuInput = document.getElementById(
|
||||||
|
"paid_service_name_ru",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const priceInput = document.getElementById(
|
||||||
|
"paid_service_price",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
const raw = priceInput.value.replace(/\D/g, "");
|
||||||
|
const num = Number(raw);
|
||||||
|
|
||||||
|
if (
|
||||||
|
nameInput.value.trim() &&
|
||||||
|
nameRuInput.value.trim() &&
|
||||||
|
!isNaN(num)
|
||||||
|
) {
|
||||||
|
const current = form.getValues("paid_extra_service");
|
||||||
|
form.setValue("paid_extra_service", [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
name: nameInput.value.trim(),
|
||||||
|
name_ru: nameRuInput.value.trim(),
|
||||||
|
price: num, // 🟢 0 ham bo‘lishi mumkin
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// inputlarni tozalaymiz
|
||||||
|
nameInput.value = "";
|
||||||
|
nameRuInput.value = "";
|
||||||
|
priceInput.value = "";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-12"
|
||||||
|
>
|
||||||
|
{t("Qo'shish")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<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"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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,78 +90,94 @@ const StepTwo = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🧩 Edit holati uchun formni to‘ldirish
|
||||||
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;
|
|
||||||
|
|
||||||
while (hasNext) {
|
while (hasNext) {
|
||||||
const res = await hotelType({ page, page_size: 50 });
|
const res = await hotelType({ page, page_size: 50 });
|
||||||
const data = res.data.data;
|
const data = res.data.data;
|
||||||
results = [...results, ...data.results];
|
results = [...results, ...data.results];
|
||||||
hasNext = !!data.links.next;
|
hasNext = !!data.links.next;
|
||||||
page++;
|
page++;
|
||||||
}
|
|
||||||
setAllHotelTypes(results);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setAllHotelTypes(results);
|
||||||
};
|
};
|
||||||
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;
|
|
||||||
|
|
||||||
while (hasNext) {
|
while (hasNext) {
|
||||||
const res = await hotelFeature({ page, page_size: 50 });
|
const res = await hotelFeature({ page, page_size: 50 });
|
||||||
const data = res.data.data;
|
const data = res.data.data;
|
||||||
results = [...results, ...data.results];
|
results = [...results, ...data.results];
|
||||||
hasNext = !!data.links.next;
|
hasNext = !!data.links.next;
|
||||||
page++;
|
page++;
|
||||||
}
|
|
||||||
setAllHotelFeature(results);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setAllHotelFeature(results);
|
||||||
};
|
};
|
||||||
loadAll();
|
loadHotelFeatures();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 🔹 Feature type'larni yuklash (tanlangan feature bo‘yicha)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedHotelFeatures.length === 0) {
|
if (selectedHotelFeatures.length === 0) {
|
||||||
setAllHotelFeatureType([]);
|
setAllHotelFeatureType([]);
|
||||||
@@ -158,107 +185,118 @@ const StepTwo = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadAll = async () => {
|
const loadFeatureTypes = async () => {
|
||||||
try {
|
const selectedIds = selectedHotelFeatures.map(Number).filter(Boolean);
|
||||||
const selectedFeatureIds = selectedHotelFeatures
|
let allResults: HotelFeaturesType[] = [];
|
||||||
.map((featureId) => Number(featureId))
|
const mapping: Record<string, string[]> = {};
|
||||||
.filter((id) => !isNaN(id));
|
|
||||||
|
|
||||||
if (selectedFeatureIds.length === 0) return;
|
for (const id of selectedIds) {
|
||||||
|
let page = 1;
|
||||||
|
let hasNext = true;
|
||||||
|
const featureTypes: string[] = [];
|
||||||
|
|
||||||
let allResults: HotelFeaturesType[] = [];
|
while (hasNext) {
|
||||||
const newMapping: Record<string, string[]> = {};
|
const res = await hotelFeatureType({
|
||||||
|
page,
|
||||||
for (const featureId of selectedFeatureIds) {
|
page_size: 50,
|
||||||
let page = 1;
|
feature_type: id,
|
||||||
let hasNext = true;
|
});
|
||||||
const featureTypes: string[] = [];
|
const data = res.data.data;
|
||||||
|
allResults = [...allResults, ...data.results];
|
||||||
while (hasNext) {
|
data.results.forEach((ft: HotelFeaturesType) =>
|
||||||
const res = await hotelFeatureType({
|
featureTypes.push(String(ft.id)),
|
||||||
page,
|
);
|
||||||
page_size: 50,
|
hasNext = !!data.links.next;
|
||||||
feature_type: featureId,
|
page++;
|
||||||
});
|
|
||||||
const data = res.data.data;
|
|
||||||
allResults = [...allResults, ...data.results];
|
|
||||||
|
|
||||||
data.results.forEach((item: HotelFeaturesType) => {
|
|
||||||
featureTypes.push(String(item.id));
|
|
||||||
});
|
|
||||||
|
|
||||||
hasNext = !!data.links.next;
|
|
||||||
page++;
|
|
||||||
}
|
|
||||||
|
|
||||||
newMapping[String(featureId)] = featureTypes;
|
|
||||||
}
|
}
|
||||||
|
mapping[String(id)] = featureTypes;
|
||||||
const uniqueResults = allResults.filter(
|
|
||||||
(item, index, self) =>
|
|
||||||
index === self.findIndex((t) => t.id === item.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
setAllHotelFeatureType(uniqueResults);
|
|
||||||
setFeatureTypeMapping(newMapping);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const uniqueResults = allResults.filter(
|
||||||
|
(v, i, a) => a.findIndex((t) => t.id === v.id) === i,
|
||||||
|
);
|
||||||
|
|
||||||
|
setAllHotelFeatureType(uniqueResults);
|
||||||
|
setFeatureTypeMapping(mapping);
|
||||||
};
|
};
|
||||||
|
|
||||||
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 onSubmit = (data: z.infer<typeof formSchema>) => {
|
const removeFeatureType = (id: string) =>
|
||||||
const formData = new FormData();
|
form.setValue(
|
||||||
formData.append("ticket", ticketId ? ticketId?.toString() : "");
|
"hotelFeaturesType",
|
||||||
formData.append("name", data.title);
|
form.getValues("hotelFeaturesType").filter((v) => v !== id),
|
||||||
formData.append("rating", String(data.rating));
|
|
||||||
formData.append(
|
|
||||||
"meal_plan",
|
|
||||||
data.mealPlan === "Breakfast Only"
|
|
||||||
? "breakfast"
|
|
||||||
: data.mealPlan === "All Inclusive"
|
|
||||||
? "all_inclusive"
|
|
||||||
: data.mealPlan === "Half Board"
|
|
||||||
? "half_board"
|
|
||||||
: data.mealPlan === "Full Board"
|
|
||||||
? "full_board"
|
|
||||||
: "all_inclusive",
|
|
||||||
);
|
);
|
||||||
|
|
||||||
data.hotelType.forEach((typeId) => {
|
// 🧩 Submit
|
||||||
formData.append("hotel_type", typeId);
|
const onSubmit = (data: z.infer<typeof formSchema>) => {
|
||||||
});
|
const formData = new FormData();
|
||||||
|
|
||||||
data.hotelFeaturesType.forEach((typeId) => {
|
formData.append("ticket", ticketId ? String(ticketId) : "");
|
||||||
formData.append("hotel_features", typeId);
|
formData.append("name", data.title);
|
||||||
});
|
formData.append("rating", data.rating);
|
||||||
|
|
||||||
|
const mealPlan =
|
||||||
|
data.mealPlan === "Breakfast Only"
|
||||||
|
? "breakfast"
|
||||||
|
: data.mealPlan === "Half Board"
|
||||||
|
? "half_board"
|
||||||
|
: data.mealPlan === "Full Board"
|
||||||
|
? "full_board"
|
||||||
|
: "all_inclusive";
|
||||||
|
formData.append("meal_plan", mealPlan);
|
||||||
|
|
||||||
|
data.hotelType.forEach((id) => formData.append("hotel_type", id));
|
||||||
|
data.hotelFeatures.forEach((id) => formData.append("hotel_features", id));
|
||||||
|
|
||||||
amenities.forEach((e, i) => {
|
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
mutate(formData);
|
if (isEditMode && hotelDetail) {
|
||||||
|
edit({
|
||||||
|
body: formData,
|
||||||
|
id: Number(hotelDetail[0].id),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
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>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||||
|
|||||||
@@ -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("O‘chirildi"), { 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 qo‘shildi"), { 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("Qo‘shish")}
|
{t("Qo'shish")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -250,81 +258,96 @@ 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 qo‘shish")
|
? t("Yangi transport qo'shish")
|
||||||
: t("Tahrirlash")}
|
: t("Tahrirlash")}
|
||||||
</p>
|
</p>
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
|
||||||
className="space-y-6 p-2"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>{t("Nomi (uz)")}</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder={t("Nomi (uz)")} {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name_ru"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>{t("Nomi (ru)")}</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder={t("Nomi (ru)")} {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="icon_name"
|
|
||||||
render={() => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>{t("Belgi (Icon)")}</FormLabel>
|
|
||||||
<FormControl className="w-full">
|
|
||||||
<IconSelect
|
|
||||||
setSelectedIcon={setSelectedIcon}
|
|
||||||
selectedIcon={selectedIcon}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-4">
|
{isDetailLoading && types === "edit" ? (
|
||||||
<Button
|
<div className="flex justify-center items-center h-64">
|
||||||
type="button"
|
<Loader className="animate-spin w-8 h-8" />
|
||||||
onClick={() => setOpen(false)}
|
</div>
|
||||||
className="border-slate-600 text-white hover:bg-gray-600 bg-gray-600"
|
) : (
|
||||||
>
|
<Form {...form}>
|
||||||
{t("Bekor qilish")}
|
<form
|
||||||
</Button>
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
<Button type="submit" disabled={isPending || updatePending}>
|
className="space-y-6 p-2"
|
||||||
{isPending || updatePending ? (
|
>
|
||||||
<Loader className="animate-spin" />
|
<FormField
|
||||||
) : types === "create" ? (
|
control={form.control}
|
||||||
t("Saqlash")
|
name="name"
|
||||||
) : (
|
render={({ field }) => (
|
||||||
t("Tahrirlash")
|
<FormItem>
|
||||||
|
<FormLabel>{t("Nomi (uz)")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t("Nomi (uz)")} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
</Button>
|
/>
|
||||||
</div>
|
<FormField
|
||||||
</form>
|
control={form.control}
|
||||||
</Form>
|
name="name_ru"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("Nomi (ru)")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t("Nomi (ru)")} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="icon_name"
|
||||||
|
render={() => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("Belgi (Icon)")}</FormLabel>
|
||||||
|
<FormControl className="w-full">
|
||||||
|
<IconSelect
|
||||||
|
setSelectedIcon={setSelectedIcon}
|
||||||
|
selectedIcon={selectedIcon}
|
||||||
|
defaultIcon="HelpCircle"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCloseDialog}
|
||||||
|
className="border-slate-600 text-white hover:bg-gray-600 bg-gray-600"
|
||||||
|
>
|
||||||
|
{t("Bekor qilish")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isPending || updatePending}>
|
||||||
|
{isPending || updatePending ? (
|
||||||
|
<Loader className="animate-spin" />
|
||||||
|
) : types === "create" ? (
|
||||||
|
t("Saqlash")
|
||||||
|
) : (
|
||||||
|
t("Tahrirlash")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 (30–60 belgi)": "Заголовок страницы (30–60 символов)",
|
||||||
|
"Meta Description": "Мета описание",
|
||||||
|
"Sahifa tavsifi (120–160 belgi)": "Описание страницы (120–160 символов)",
|
||||||
|
"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 Ma’lumotlari": "Сохранённые SEO данные",
|
||||||
|
"Hozircha SEO ma’lumotlari mavjud emas.": "Пока нет данных по SEO.",
|
||||||
|
"Ma’lumotlar 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": "Выберите тип страницы",
|
||||||
|
"Qo‘llanma": "Инструкция",
|
||||||
|
"Maxfiylik siyosati": "Политика конфиденциальности",
|
||||||
|
"Faol emas": "Неактивно",
|
||||||
|
"Yaratish": "Создать",
|
||||||
|
"Natija topilmadi.": "Результаты не найдены.",
|
||||||
|
"O‘chirishni tasdiqlash": "Подтверждение удаления",
|
||||||
|
"Haqiqatan ham bu yordam sahifasini o‘chirmoqchimisiz? Bu amalni bekor qilib bo‘lmaydi.": "Вы действительно хотите удалить эту страницу помощи? Это действие нельзя отменить.",
|
||||||
|
"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 yo‘nalishlar": "Известные направления",
|
||||||
|
"Reytingi baland turlar": "Высокорейтинговые туры",
|
||||||
|
"Status muvaffaqiyatli yangilandi": "Статус успешно обновлён",
|
||||||
|
"Statusni yangilashda xatolik yuz berdi": "Ошибка обновления статуса",
|
||||||
|
"Refunded": "Подтверждено"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 qo‘shish": "Yangi kategoriya qo‘shish",
|
"Yangi kategoriya qo‘shish": "Yangi kategoriya qo‘shish",
|
||||||
"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": "To‘liq pansion (nonushta, tushlik va kechki ovqat)",
|
"Full Board": "To‘liq pansion (nonushta, tushlik va kechki ovqat)",
|
||||||
"All Inclusive": "To‘liq pansion (nonushta, tushlik va kechki ovqat)"
|
"All Inclusive": "To‘liq 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": "To‘lov 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 (30–60 belgi)": "Sahifa sarlavhasi (30–60 belgi)",
|
||||||
|
"Meta Description": "Meta tavsif",
|
||||||
|
"Sahifa tavsifi (120–160 belgi)": "Sahifa tavsifi (120–160 belgi)",
|
||||||
|
"Keywords": "Kalit so‘zlar",
|
||||||
|
"Kalit so'zlar (vergul bilan ajratilgan)": "Kalit so‘zlar (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 Ma’lumotlari": "Saqlangan SEO ma’lumotlari",
|
||||||
|
"Hozircha SEO ma’lumotlari mavjud emas.": "Hozircha SEO ma’lumotlari mavjud emas.",
|
||||||
|
"Ma’lumotlar muvaffaqiyatli saqlandi": "Ma’lumotlar 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",
|
||||||
|
"Qo‘llanma": "Qo‘llanma",
|
||||||
|
"Maxfiylik siyosati": "Maxfiylik siyosati",
|
||||||
|
"Faol emas": "Faol emas",
|
||||||
|
"Yaratish": "Yaratish",
|
||||||
|
"Natija topilmadi.": "Natija topilmadi.",
|
||||||
|
"O‘chirishni tasdiqlash": "O‘chirishni tasdiqlash",
|
||||||
|
"Haqiqatan ham bu yordam sahifasini o‘chirmoqchimisiz? Bu amalni bekor qilib bo‘lmaydi.": "Haqiqatan ham bu yordam sahifasini o‘chirmoqchimisiz? Bu amalni bekor qilib bo‘lmaydi.",
|
||||||
|
"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 yo‘nalishlar": "Mashhur yo‘nalishlar",
|
||||||
|
"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
239
src/shared/ui/carousel.tsx
Normal 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,
|
||||||
|
}
|
||||||
104
src/shared/ui/infiniteScrollSelect.tsx
Normal file
104
src/shared/ui/infiniteScrollSelect.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
29
src/shared/ui/switch.tsx
Normal 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 }
|
||||||
@@ -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/" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user