api ulandi

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

View File

@@ -1,4 +1,9 @@
import type { Faq, FaqCategory, FaqCategoryDetail } from "@/pages/faq/lib/type";
import type {
Faq,
FaqCategory,
FaqCategoryDetail,
FaqOne,
} from "@/pages/faq/lib/type";
import httpClient from "@/shared/config/api/httpClient";
import { FAQ, FAQ_CATEGORIES } from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios";
@@ -6,11 +11,50 @@ import type { AxiosResponse } from "axios";
const getAllFaq = async (params: {
page: number;
page_size: number;
category: number;
}): Promise<AxiosResponse<Faq>> => {
const res = await httpClient.get(FAQ, { params });
return res;
};
const getOneFaq = async (id: number): Promise<AxiosResponse<FaqOne>> => {
const res = await httpClient.get(`${FAQ}${id}/`);
return res;
};
const createFaq = async (body: {
title: string;
title_ru: string;
text: string;
text_ru: string;
category: number;
}) => {
const res = await httpClient.post(FAQ, body);
return res;
};
const updateFaq = async ({
body,
id,
}: {
id: number;
body: {
title: string;
title_ru: string;
text: string;
text_ru: string;
category?: number;
};
}) => {
const res = await httpClient.patch(`${FAQ}${id}/`, body);
return res;
};
const deleteFaq = async ({ id }: { id: number }) => {
const res = await httpClient.delete(`${FAQ}${id}/`);
return res;
};
const getAllFaqCategory = async (params: {
page: number;
page_size: number;
@@ -48,10 +92,14 @@ const deleteFaqCategory = async (id: number) => {
};
export {
createFaq,
createFaqCategory,
deleteFaq,
deleteFaqCategory,
getAllFaq,
getAllFaqCategory,
getDetailFaqCategory,
getOneFaq,
updateFaq,
updateFaqCategory,
};

View File

@@ -21,6 +21,7 @@ export interface FaqCategoryDetail {
data: {
id: number;
name: string;
name_uz: string;
name_ru: string;
};
}
@@ -48,3 +49,16 @@ export interface Faq {
}[];
};
}
export interface FaqOne {
status: boolean;
data: {
id: number;
title: string;
title_uz: string;
title_ru: string;
text: string;
text_ru: string;
text_uz: string;
};
}

View File

@@ -1,6 +1,13 @@
"use client";
import { getAllFaq, getAllFaqCategory } from "@/pages/faq/lib/api";
import {
createFaq,
deleteFaq,
getAllFaq,
getAllFaqCategory,
getOneFaq,
updateFaq,
} from "@/pages/faq/lib/api";
import { Button } from "@/shared/ui/button";
import {
Dialog,
@@ -16,16 +23,9 @@ import {
FormItem,
FormMessage,
} from "@/shared/ui/form";
import { InfiniteScrollSelect } from "@/shared/ui/infiniteScrollSelect";
import { Input } from "@/shared/ui/input";
import { Label } from "@/shared/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/shared/ui/select";
import {
Table,
TableBody,
@@ -37,70 +37,231 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import { Textarea } from "@/shared/ui/textarea";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Pencil, PlusCircle, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
ChevronRight,
Loader2,
Pencil,
PlusCircle,
Trash2,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import z from "zod";
const faqForm = z.object({
categories: z.string().min(1, { message: "Majburiy maydon" }),
title: z.string().min(1, { message: "Majburiy maydon" }),
title_ru: z.string().min(1, { message: "Majburiy maydon" }),
answer: z.string().min(1, { message: "Majburiy maydon" }),
answer_ru: z.string().min(1, { message: "Majburiy maydon" }),
});
const Faq = () => {
const { t } = useTranslation();
const { data: category } = useQuery({
queryKey: ["all_faqcategory"],
queryFn: () => {
return getAllFaqCategory({ page: 1, page_size: 99 });
},
select(data) {
return data.data.data.results;
},
});
const { data: faq } = useQuery({
queryKey: ["all_faq"],
queryFn: () => {
return getAllFaq({ page: 1, page_size: 99 });
},
select(data) {
return data.data.data.results;
},
});
const [activeTab, setActiveTab] = useState<string>("");
const { t } = useTranslation();
const [openModal, setOpenModal] = useState(false);
const [editFaq, setEditFaq] = useState<number | null>(null);
const queryClient = useQueryClient();
const scrollRef = useRef<HTMLDivElement>(null);
const loaderRef = useRef<HTMLDivElement>(null);
// Infinite scroll uchun useInfiniteQuery
const {
data: categoryData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ["all_faqcategory"],
queryFn: ({ pageParam = 1 }) => {
return getAllFaqCategory({ page: pageParam, page_size: 10 });
},
getNextPageParam: (lastPage) => {
const data = lastPage.data.data;
if (data.current_page < data.total_pages) {
return data.current_page + 1;
}
return undefined;
},
initialPageParam: 1,
});
// Barcha kategoriyalarni birlashtirib olish
const category =
categoryData?.pages.flatMap((page) => page.data.data.results) ?? [];
const { data: faq } = useQuery({
queryKey: ["all_faq", activeTab],
queryFn: () => {
return getAllFaq({ page: 1, page_size: 10, category: Number(activeTab) });
},
select(data) {
return data.data.data.results;
},
enabled: !!activeTab,
});
const { data: detailFaq } = useQuery({
queryKey: ["detail_faq", editFaq],
queryFn: () => {
return getOneFaq(editFaq!);
},
select(data) {
return data.data.data;
},
enabled: !!editFaq,
});
const { mutate: create, isPending } = useMutation({
mutationFn: (body: {
title: string;
title_ru: string;
text: string;
text_ru: string;
category: number;
}) => createFaq(body),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["all_faq"] });
setOpenModal(false);
toast.success(t("Muvaffaqiyatli qo'shildi"), { position: "top-center" });
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
position: "top-center",
richColors: true,
});
},
});
const { mutate: edit, isPending: editPending } = useMutation({
mutationFn: ({
body,
id,
}: {
id: number;
body: {
title: string;
title_ru: string;
text: string;
text_ru: string;
category?: number;
};
}) => updateFaq({ body, id }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["all_faq"] });
setOpenModal(false);
toast.success(t("Tahrirlandi"), { position: "top-center" });
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
position: "top-center",
richColors: true,
});
},
});
const { mutate: deleteFaqs, isPending: deletePending } = useMutation({
mutationFn: ({ id }: { id: number }) => deleteFaq({ id }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["all_faq"] });
setDeleteId(null);
toast.success(t("O'chirildi"), { position: "top-center" });
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
position: "top-center",
richColors: true,
});
},
});
useEffect(() => {
if (category) {
if (category.length > 0 && !activeTab) {
setActiveTab(String(category[0].id));
}
}, [category]);
}, [category, activeTab]);
// Intersection Observer for lazy loading
useEffect(() => {
if (!scrollRef.current || !loaderRef.current) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ root: scrollRef.current, threshold: 0.1 },
);
if (loaderRef.current) {
observer.observe(loaderRef.current);
}
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [editFaq, setEditFaq] = useState<any | null>(null);
const [openModal, setOpenModal] = useState(false);
const form = useForm<z.infer<typeof faqForm>>({
resolver: zodResolver(faqForm),
defaultValues: {
answer: "",
answer_ru: "",
categories: "",
title: "",
title_ru: "",
},
});
useEffect(() => {
if (detailFaq) {
form.setValue("title", detailFaq.title_uz);
form.setValue("title_ru", detailFaq.title_ru);
form.setValue("answer", detailFaq.text_uz);
form.setValue("answer_ru", detailFaq.text_ru);
form.setValue("categories", activeTab);
}
}, [detailFaq, form]);
function onSubmit(value: z.infer<typeof faqForm>) {
console.log(value);
if (editFaq === null) {
create({
category: Number(value.categories),
text: value.answer,
text_ru: value.answer_ru,
title: value.title,
title_ru: value.title_ru,
});
} else if (editFaq) {
edit({
body: {
text: value.answer,
text_ru: value.answer_ru,
title: value.title,
title_ru: value.title_ru,
},
id: editFaq,
});
}
}
const handleEdit = (faq: number) => {
setOpenModal(true);
setEditFaq(faq);
};
const handleDelete = () => {
if (deleteId) {
deleteFaqs({ id: deleteId });
}
};
@@ -125,22 +286,42 @@ const Faq = () => {
setOpenModal(true);
}}
>
<PlusCircle className="w-4 h-4" /> {t("Yangi qoshish")}
<PlusCircle className="w-4 h-4" /> {t("Yangi qo'shish")}
</Button>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="flex flex-wrap gap-2 mb-4">
{category?.map((cat) => (
<TabsTrigger key={cat.id} value={String(cat.id)}>
{cat.name}
</TabsTrigger>
))}
</TabsList>
<div className="relative">
<TabsList
ref={scrollRef}
className="flex gap-2 mb-4 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-gray-600 scrollbar-track-gray-800"
>
{category.map((cat) => (
<TabsTrigger
key={cat.id}
value={String(cat.id)}
className="whitespace-nowrap"
>
{cat.name}
</TabsTrigger>
))}
{hasNextPage && (
<div ref={loaderRef} className="flex items-center px-4">
{isFetchingNextPage ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<span className="text-xs text-gray-500">
<ChevronRight className="size-4" />
</span>
)}
</div>
)}
</TabsList>
</div>
{/* Tabs content */}
{category?.map((cat) => (
{category.map((cat) => (
<TabsContent key={cat.id} value={String(cat.id)}>
{faq && faq?.length > 0 ? (
<div className="border rounded-md overflow-hidden shadow-sm">
@@ -192,7 +373,7 @@ const Faq = () => {
</div>
) : (
<p className="text-gray-500 text-sm mt-4 text-center">
{t("Bu bolimda savollar yoq.")}
{t("Bu bo'limda savollar yo'q")}
</p>
)}
</TabsContent>
@@ -200,10 +381,10 @@ const Faq = () => {
</Tabs>
<Dialog open={openModal} onOpenChange={setOpenModal}>
<DialogContent className="sm:max-w-[500px] bg-gray-900">
<DialogContent className="sm:max-w-[500px] h-[80vh] overflow-y-scroll bg-gray-900">
<DialogHeader>
<DialogTitle>
{editFaq ? t("FAQni tahrirlash") : t("Yangi FAQ qoshish")}
{editFaq ? t("FAQni tahrirlash") : t("Yangi FAQ qo'shish")}
</DialogTitle>
</DialogHeader>
@@ -216,24 +397,37 @@ const Faq = () => {
<FormItem>
<Label className="text-md">{t("Kategoriya")}</Label>
<FormControl>
<Select
onValueChange={field.onChange}
<InfiniteScrollSelect
value={field.value}
>
<SelectTrigger className="w-full !h-12 border-gray-700 text-white">
<SelectValue placeholder={t("Kategoriya tanlang")} />
</SelectTrigger>
<SelectContent className="border-gray-700 text-white">
<SelectGroup>
<SelectLabel>{t("Kategoriyalar")}</SelectLabel>
{/* {categories.map((cat) => (
<SelectItem key={cat.value} value={cat.value}>
{cat.label}
</SelectItem>
))} */}
</SelectGroup>
</SelectContent>
</Select>
onValueChange={field.onChange}
placeholder={t("Kategoriya tanlang")}
label={t("Kategoriyalar")}
data={category || []}
fetchNextPage={fetchNextPage}
renderOption={(cat) => ({
key: cat.id,
value: String(cat.id),
label: cat.name,
})}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<Label className="text-md">{t("Savol")}</Label>
<FormControl>
<Input
placeholder={t("Savol")}
{...field}
className="h-12 !text-md bg-gray-800 border-gray-700 text-white"
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -241,10 +435,10 @@ const Faq = () => {
/>
<FormField
control={form.control}
name="title"
name="title_ru"
render={({ field }) => (
<FormItem>
<Label className="text-md">{t("Savol")}</Label>
<Label className="text-md">{t("Savol")} (ru)</Label>
<FormControl>
<Input
placeholder={t("Savol")}
@@ -273,6 +467,23 @@ const Faq = () => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="answer_ru"
render={({ field }) => (
<FormItem>
<Label className="text-md">{t("Javob")} (ru)</Label>
<FormControl>
<Textarea
placeholder={t("Javob") + " (ru)"}
{...field}
className="min-h-48 max-h-56 !text-md bg-gray-800 border-gray-700 text-white"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-between">
<Button
type="button"
@@ -288,7 +499,11 @@ const Faq = () => {
type="submit"
className="bg-blue-600 px-5 py-5 hover:bg-blue-700 text-white mt-4 cursor-pointer"
>
{t("Qo'shish")}
{isPending || editPending ? (
<Loader2 className="animate-spin" />
) : (
t("Qo'shish")
)}
</Button>
</div>
</form>
@@ -299,14 +514,18 @@ const Faq = () => {
<Dialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>{t("Haqiqatan ham ochirmoqchimisiz?")}</DialogTitle>
<DialogTitle>{t("Haqiqatan ham o'chirmoqchimisiz?")}</DialogTitle>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)}>
{t("Bekor qilish")}
</Button>
<Button variant="destructive" onClick={handleDelete}>
{t("O'chirish")}
{deletePending ? (
<Loader2 className="animate-spin" />
) : (
t("O'chirish")
)}
</Button>
</DialogFooter>
</DialogContent>

View File

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

View File

@@ -0,0 +1,42 @@
import type {
UserOrderData,
UserOrderDetailData,
} from "@/pages/finance/lib/type";
import httpClient from "@/shared/config/api/httpClient";
import { USER_ORDERS } from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios";
const getAllOrder = async (params: {
page: number;
page_size: number;
}): Promise<AxiosResponse<UserOrderData>> => {
const res = await httpClient.get(USER_ORDERS, { params });
return res;
};
const getDetailOrder = async (
id: number,
): Promise<AxiosResponse<UserOrderDetailData>> => {
const res = await httpClient.get(`${USER_ORDERS}${id}/`);
return res;
};
const updateDetailOrder = async ({
id,
body,
}: {
id: number;
body: {
order_status:
| "pending_payment"
| "pending_confirmation"
| "cancelled"
| "confirmed"
| "completed";
};
}) => {
const res = await httpClient.patch(`${USER_ORDERS}${id}/`, body);
return res;
};
export { getAllOrder, getDetailOrder, updateDetailOrder };

View File

@@ -0,0 +1,106 @@
export interface UserOrderData {
status: boolean;
data: {
links: {
previous: string;
next: string;
};
total_items: number;
total_pages: number;
page_size: number;
current_page: number;
results: {
orders: {
id: number;
user: {
first_name: string;
last_name: string;
contact: string;
};
destination: string;
total_price: number;
order_status:
| "pending_payment"
| "pending_confirmation"
| "cancelled"
| "confirmed"
| "completed";
tour_name: string;
}[];
total_income: string;
};
};
}
export interface OrderStatus {
order_status:
| "pending_payment"
| "pending_confirmation"
| "cancelled"
| "confirmed"
| "completed";
}
export interface UserOrderDetailData {
status: boolean;
data: {
id: number;
user: {
id: number;
last_login: string;
is_superuser: true;
first_name: string;
last_name: string;
is_staff: true;
is_active: true;
date_joined: string;
phone: string;
email: string;
username: string;
avatar: string;
validated_at: string;
role: string;
total_spent: number;
travel_agency: number;
};
departure: string;
destination: string;
departure_date: string;
arrival_time: string;
participant: [
{
first_name: string;
last_name: string;
birth_date: string;
phone_number: string;
gender: "male" | "female";
participant_pasport_image: [
{
image: string;
},
];
},
];
ticket: number;
tariff: string;
transport: string;
extra_service: [
{
name: string;
},
];
extra_paid_service: [
{
name: string;
price: number;
},
];
order_status:
| "pending_payment"
| "pending_confirmation"
| "cancelled"
| "confirmed"
| "completed";
total_price: number;
};
}

View File

@@ -1,11 +1,18 @@
"use client";
import { getAllOrder } from "@/pages/finance/lib/api";
import type { OrderStatus } from "@/pages/finance/lib/type";
import formatPhone from "@/shared/lib/formatPhone";
import formatPrice from "@/shared/lib/formatPrice";
import { Button } from "@/shared/ui/button";
import { useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
CreditCard,
DollarSign,
Eye,
Hotel,
Loader2,
MapPin,
Plane,
TrendingUp,
@@ -15,94 +22,6 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
type Purchase = {
id: number;
userName: string;
userPhone: string;
tourName: string;
tourId: number;
agencyName: string;
agencyId: number;
destination: string;
travelDate: string;
amount: number;
paymentStatus: "paid" | "pending" | "cancelled" | "refunded";
purchaseDate: string;
};
const mockPurchases: Purchase[] = [
{
id: 1,
userName: "Aziza Karimova",
userPhone: "+998 90 123 45 67",
tourName: "Dubai Luxury Tour",
tourId: 1,
agencyName: "Silk Road Travel",
agencyId: 1,
destination: "Dubai, UAE",
travelDate: "2025-11-10",
amount: 1500000,
paymentStatus: "paid",
purchaseDate: "2025-10-10",
},
{
id: 2,
userName: "Sardor Rahimov",
userPhone: "+998 91 234 56 78",
tourName: "Bali Adventure Package",
tourId: 2,
agencyName: "Silk Road Travel",
agencyId: 1,
destination: "Bali, Indonesia",
travelDate: "2025-11-15",
amount: 1800000,
paymentStatus: "paid",
purchaseDate: "2025-10-12",
},
{
id: 3,
userName: "Nilufar Toshmatova",
userPhone: "+998 93 345 67 89",
tourName: "Dubai Luxury Tour",
tourId: 1,
agencyName: "Silk Road Travel",
agencyId: 1,
destination: "Dubai, UAE",
travelDate: "2025-11-20",
amount: 1500000,
paymentStatus: "pending",
purchaseDate: "2025-10-14",
},
{
id: 4,
userName: "Jamshid Alimov",
userPhone: "+998 94 456 78 90",
tourName: "Istanbul Express Tour",
tourId: 3,
agencyName: "Orient Express",
agencyId: 3,
destination: "Istanbul, Turkey",
travelDate: "2025-11-05",
amount: 1200000,
paymentStatus: "cancelled",
purchaseDate: "2025-10-08",
},
{
id: 5,
userName: "Madina Yusupova",
userPhone: "+998 97 567 89 01",
tourName: "Paris Romantic Getaway",
tourId: 4,
agencyName: "Euro Travels",
agencyId: 2,
destination: "Paris, France",
travelDate: "2025-12-01",
amount: 2200000,
paymentStatus: "paid",
purchaseDate: "2025-10-16",
},
];
export default function FinancePage() {
const { t } = useTranslation();
const [tab, setTab] = useState<"bookings" | "agencies">("bookings");
@@ -110,28 +29,56 @@ export default function FinancePage() {
"all" | "paid" | "pending" | "cancelled" | "refunded"
>("all");
const getStatusBadge = (status: Purchase["paymentStatus"]) => {
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["list_order_user"],
queryFn: () => getAllOrder({ page: 1, page_size: 10 }),
});
const getStatusBadge = (status: OrderStatus["order_status"]) => {
const base =
"px-3 py-1 rounded-full text-sm font-medium inline-flex items-center gap-2";
switch (status) {
case "paid":
return (
<span
className={`${base} bg-green-900 text-green-400 border border-green-700`}
>
<div className="w-2 h-2 rounded-full bg-green-400"></div>
{t("Paid")}
</span>
);
case "pending":
case "pending_payment":
return (
<span
className={`${base} bg-yellow-900 text-yellow-400 border border-yellow-700`}
>
<div className="w-2 h-2 rounded-full bg-yellow-400"></div>
{t("Pending")}
{t("Pending Payment")}
</span>
);
case "pending_confirmation":
return (
<span
className={`${base} bg-orange-900 text-orange-400 border border-orange-700`}
>
<div className="w-2 h-2 rounded-full bg-orange-400"></div>
{t("Pending Confirmation")}
</span>
);
case "confirmed":
return (
<span
className={`${base} bg-blue-900 text-blue-400 border border-blue-700`}
>
<div className="w-2 h-2 rounded-full bg-blue-400"></div>
{t("Confirmed")}
</span>
);
case "completed":
return (
<span
className={`${base} bg-green-900 text-green-400 border border-green-700`}
>
<div className="w-2 h-2 rounded-full bg-green-400"></div>
{t("Completed")}
</span>
);
case "cancelled":
return (
<span
@@ -141,49 +88,37 @@ export default function FinancePage() {
{t("Cancelled")}
</span>
);
case "refunded":
return (
<span
className={`${base} bg-blue-900 text-blue-400 border border-blue-700`}
>
<div className="w-2 h-2 rounded-full bg-blue-400"></div>
Refunded
</span>
);
default:
return null;
}
};
const filteredPurchases =
filterStatus === "all"
? mockPurchases
: mockPurchases.filter((p) => p.paymentStatus === filterStatus);
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 text-white gap-4 w-full">
<Loader2 className="w-10 h-10 animate-spin text-cyan-400" />
<p className="text-slate-400">{t("Ma'lumotlar yuklanmoqda...")}</p>
</div>
);
}
const totalRevenue = filteredPurchases
.filter((p) => p.paymentStatus === "paid")
.reduce((sum, p) => sum + p.amount, 0);
const pendingRevenue = filteredPurchases
.filter((p) => p.paymentStatus === "pending")
.reduce((sum, p) => sum + p.amount, 0);
const agencies = Array.from(
new Set(mockPurchases.map((p) => p.agencyId)),
).map((id) => {
const agencyPurchases = mockPurchases.filter((p) => p.agencyId === id);
return {
id,
name: agencyPurchases[0].agencyName,
totalPaid: agencyPurchases
.filter((p) => p.paymentStatus === "paid")
.reduce((sum, p) => sum + p.amount, 0),
pending: agencyPurchases
.filter((p) => p.paymentStatus === "pending")
.reduce((sum, p) => sum + p.amount, 0),
purchaseCount: agencyPurchases.length,
destinations: Array.from(
new Set(agencyPurchases.map((p) => p.destination)),
),
};
});
if (isError) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 w-full text-center text-white gap-4">
<AlertTriangle className="w-10 h-10 text-red-500" />
<p className="text-lg">
{t("Ma'lumotlarni yuklashda xatolik yuz berdi.")}
</p>
<Button
onClick={() => refetch()}
className="bg-gradient-to-r from-blue-600 to-cyan-600 text-white rounded-lg px-5 py-2 hover:opacity-90"
>
{t("Qayta urinish")}
</Button>
</div>
);
}
return (
<div className="min-h-screen w-full bg-gray-900 text-gray-100">
@@ -275,7 +210,7 @@ export default function FinancePage() {
<DollarSign className="text-green-400 w-6 h-6" />
</div>
<p className="text-2xl font-bold text-green-400 mt-3">
{formatPrice(totalRevenue, true)}
{/* {formatPrice(totalRevenue, true)} */}
</p>
<p className="text-sm text-gray-500 mt-1">
{t("Yakunlangan bandlovlardan")}
@@ -289,7 +224,7 @@ export default function FinancePage() {
<TrendingUp className="text-yellow-400 w-6 h-6" />
</div>
<p className="text-2xl font-bold text-yellow-400 mt-3">
{formatPrice(pendingRevenue, true)}
{/* {formatPrice(pendingRevenue, true)} */}
</p>
<p className="text-sm text-gray-500 mt-1">
{t("Tasdiqlash kutilmoqda")}
@@ -303,10 +238,10 @@ export default function FinancePage() {
<CreditCard className="text-blue-400 w-6 h-6" />
</div>
<p className="text-2xl font-bold text-blue-400 mt-3">
{
{/* {
filteredPurchases.filter((p) => p.paymentStatus === "paid")
.length
}
} */}
</p>
<p className="text-sm text-gray-500 mt-1">
{t("Tasdiqlangan bandlovlar")}
@@ -320,11 +255,11 @@ export default function FinancePage() {
<Hotel className="text-purple-400 w-6 h-6" />
</div>
<p className="text-2xl font-bold text-purple-400 mt-3">
{
{/* {
filteredPurchases.filter(
(p) => p.paymentStatus === "pending",
).length
}
} */}
</p>
<p className="text-sm text-gray-500 mt-1">
{t("Kutilayotgan tolovlar")}
@@ -335,46 +270,50 @@ export default function FinancePage() {
{/* Booking Cards */}
<h2 className="text-xl font-bold mb-4">{t("Oxirgi bandlovlar")}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredPurchases.map((p) => (
{data?.data.data.results.orders.map((p, index) => (
<div
key={p.id}
key={index}
className="bg-gray-800 p-5 rounded-xl shadow hover:shadow-md transition-all flex flex-col justify-between"
>
<div>
<div className="flex justify-between items-start mb-3">
<h2 className="text-lg font-bold text-gray-100">
{p.userName}
{p.user.first_name} {p.user.last_name}
</h2>
</div>
<p className="text-gray-400 text-sm">{p.userPhone}</p>
<p className="text-gray-400 text-sm">
{p.user.contact.includes("gmail.com")
? p.user.contact
: formatPhone(p.user.contact)}
</p>
<p className="mt-3 font-semibold text-gray-200">
{p.tourName}
{p.tour_name}
</p>
<div className="flex items-center gap-1 mt-1 text-gray-400">
<MapPin size={14} />
<p className="text-sm">{p.destination}</p>
</div>
<div className="flex justify-between mt-3">
<div>
<div className="flex justify-between items-center mt-3">
{/* <div>
<p className="text-gray-500 text-sm">
{t("Sayohat sanasi")}
</p>
<p className="text-gray-100 font-medium">
{p.travelDate}
</p>
</div>
<div className="text-right">
</div> */}
<div className="text-start">
<p className="text-gray-500 text-sm">{t("Miqdor")}</p>
<p className="text-green-400 font-bold">
{formatPrice(p.amount, true)}
{formatPrice(p.total_price, true)}
</p>
</div>
<div>{getStatusBadge(p.order_status)}</div>
</div>
</div>
<div className="mt-4 flex justify-between items-center">
{getStatusBadge(p.paymentStatus)}
<div className="mt-4 items-center">
<Link to={`/bookings/${p.id}`}>
<button className="bg-blue-600 text-white px-3 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-1 transition-colors">
<button className="bg-blue-600 text-white px-3 py-2 w-full justify-center cursor-pointer rounded-lg hover:bg-blue-700 flex items-center gap-1 transition-colors">
<Eye className="w-4 h-4" /> {t("Ko'rish")}
</button>
</Link>
@@ -385,7 +324,7 @@ export default function FinancePage() {
</>
)}
{tab === "agencies" && (
{/* {tab === "agencies" && (
<>
<h2 className="text-xl font-bold mb-6">Partner Agencies</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@@ -441,7 +380,7 @@ export default function FinancePage() {
))}
</div>
</>
)}
)} */}
</div>
</div>
);

View File

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

View File

@@ -1,7 +1,17 @@
"use client";
import { getDetailOrder, updateDetailOrder } from "@/pages/finance/lib/api";
import type { OrderStatus } from "@/pages/finance/lib/type";
import formatPhone from "@/shared/lib/formatPhone";
import formatPrice from "@/shared/lib/formatPrice";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/ui/select";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft,
Calendar,
@@ -10,129 +20,80 @@ import {
Mail,
MapPin,
Phone,
TrendingUp,
User,
UsersIcon,
} from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
type UserPurchase = {
id: number;
userName: string;
userPhone: string;
userEmail: string;
tourName: string;
tourId: number;
agencyName: string;
agencyId: number;
destination: string;
travelDate: string;
returnDate: string;
amount: number;
paymentStatus: "paid" | "pending" | "cancelled" | "refunded";
paymentMethod: "credit_card" | "paypal" | "bank_transfer" | "crypto";
purchaseDate: string;
travelers: number;
bookingReference: string;
};
const mockUserData = {
id: 1,
userName: "Aziza Karimova",
userPhone: "+998 90 123 45 67",
userEmail: "aziza.karimova@example.com",
joinDate: "2024-01-15",
totalSpent: 4500000,
totalBookings: 3,
memberLevel: "Gold",
};
const mockUserPurchases: UserPurchase[] = [
{
id: 1,
userName: "Aziza Karimova",
userPhone: "+998 90 123 45 67",
userEmail: "aziza.karimova@example.com",
tourName: "Dubai Luxury Tour",
tourId: 1,
agencyName: "Silk Road Travel",
agencyId: 1,
destination: "Dubai, UAE",
travelDate: "2025-11-10",
returnDate: "2025-11-17",
amount: 1500000,
paymentStatus: "paid",
paymentMethod: "credit_card",
purchaseDate: "2025-10-10",
travelers: 2,
bookingReference: "TRV-DXB-001",
},
{
id: 2,
userName: "Aziza Karimova",
userPhone: "+998 90 123 45 67",
userEmail: "aziza.karimova@example.com",
tourName: "Paris Romantic Getaway",
tourId: 4,
agencyName: "Euro Travels",
agencyId: 2,
destination: "Paris, France",
travelDate: "2025-12-01",
returnDate: "2025-12-08",
amount: 2200000,
paymentStatus: "paid",
paymentMethod: "paypal",
purchaseDate: "2025-10-16",
travelers: 2,
bookingReference: "TRV-PAR-002",
},
{
id: 3,
userName: "Aziza Karimova",
userPhone: "+998 90 123 45 67",
userEmail: "aziza.karimova@example.com",
tourName: "Bali Adventure Package",
tourId: 2,
agencyName: "Silk Road Travel",
agencyId: 1,
destination: "Bali, Indonesia",
travelDate: "2025-11-15",
returnDate: "2025-11-22",
amount: 1800000,
paymentStatus: "pending",
paymentMethod: "bank_transfer",
purchaseDate: "2025-10-12",
travelers: 1,
bookingReference: "TRV-BAL-003",
},
];
import { Link, useParams } from "react-router-dom";
import { toast } from "sonner";
export default function FinanceDetailUser() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<"bookings" | "details">(
"bookings",
);
const getStatusBadge = (status: UserPurchase["paymentStatus"]) => {
const params = useParams();
const { data } = useQuery({
queryKey: ["detail_order"],
queryFn: () => getDetailOrder(Number(params.id)),
select(data) {
return data.data.data;
},
});
const { mutate } = useMutation({
mutationFn: ({
id,
body,
}: {
id: number;
body: {
order_status:
| "pending_payment"
| "pending_confirmation"
| "cancelled"
| "confirmed"
| "completed";
};
}) => updateDetailOrder({ body, id }),
onSuccess: () => {
toast.success(t("Status muvaffaqiyatli yangilandi"), {
richColors: true,
position: "top-center",
});
queryClient.invalidateQueries({ queryKey: ["detail_order"] });
},
onError: () => {
toast.error(t("Statusni yangilashda xatolik yuz berdi"), {
richColors: true,
position: "top-center",
});
},
});
const getStatusBadge = (status: OrderStatus["order_status"]) => {
const base =
"px-3 py-1 rounded-full text-sm font-medium inline-flex items-center gap-2";
switch (status) {
case "paid":
return (
<span
className={`${base} bg-green-900 text-green-400 border border-green-700`}
>
<div className="w-2 h-2 rounded-full bg-green-400"></div>
{t("Paid")}
</span>
);
case "pending":
case "pending_confirmation":
return (
<span
className={`${base} bg-yellow-900 text-yellow-400 border border-yellow-700`}
>
<div className="w-2 h-2 rounded-full bg-yellow-400"></div>
{t("Paid")}
</span>
);
case "pending_payment":
return (
<span
className={`${base} bg-green-900 text-green-400 border border-green-700`}
>
<div className="w-2 h-2 rounded-full bg-green-400"></div>
{t("Pending")}
</span>
);
@@ -145,39 +106,18 @@ export default function FinanceDetailUser() {
{t("Cancelled")}
</span>
);
case "refunded":
case "confirmed":
return (
<span
className={`${base} bg-blue-900 text-blue-400 border border-blue-700`}
>
<div className="w-2 h-2 rounded-full bg-blue-400"></div>
Refunded
{t("Refunded")}
</span>
);
}
};
const getPaymentMethod = (method: UserPurchase["paymentMethod"]) => {
switch (method) {
case "credit_card":
return "Credit Card";
case "paypal":
return "PayPal";
case "bank_transfer":
return "Bank Transfer";
case "crypto":
return "Cryptocurrency";
}
};
const totalSpent = mockUserPurchases
.filter((p) => p.paymentStatus === "paid")
.reduce((sum, p) => sum + p.amount, 0);
const pendingAmount = mockUserPurchases
.filter((p) => p.paymentStatus === "pending")
.reduce((sum, p) => sum + p.amount, 0);
return (
<div className="min-h-screen w-full bg-gray-900 text-gray-100">
<div className="w-[90%] mx-auto py-6">
@@ -195,67 +135,12 @@ export default function FinanceDetailUser() {
{t("Foydalanuvchi moliyaviy tafsilotlari")}
</h1>
<p className="text-gray-400 mt-1">
{mockUserData.userName} {t("uchun batafsil moliyaviy sharh")}
{data?.user.username} {t("uchun batafsil moliyaviy sharh")}
</p>
</div>
</div>
</div>
{/* User Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<div className="bg-gray-800 p-6 rounded-xl shadow">
<div className="flex items-center justify-between">
<p className="text-gray-400 font-medium">{t("Total Spent")}</p>
<DollarSign className="text-green-400 w-6 h-6" />
</div>
<p className="text-2xl font-bold text-green-400 mt-3">
{formatPrice(totalSpent, true)}
</p>
<p className="text-sm text-gray-500 mt-1">
{t("All completed bookings")}
</p>
</div>
<div className="bg-gray-800 p-6 rounded-xl shadow">
<div className="flex items-center justify-between">
<p className="text-gray-400 font-medium">
{t("Pending Payments")}
</p>
<TrendingUp className="text-yellow-400 w-6 h-6" />
</div>
<p className="text-2xl font-bold text-yellow-400 mt-3">
{formatPrice(pendingAmount, true)}
</p>
<p className="text-sm text-gray-500 mt-1">
{t("Awaiting confirmation")}
</p>
</div>
<div className="bg-gray-800 p-6 rounded-xl shadow">
<div className="flex items-center justify-between">
<p className="text-gray-400 font-medium">{t("Total Bookings")}</p>
<CreditCard className="text-blue-400 w-6 h-6" />
</div>
<p className="text-2xl font-bold text-blue-400 mt-3">
{mockUserData.totalBookings}
</p>
<p className="text-sm text-gray-500 mt-1">
{t("All time bookings")}
</p>
</div>
{/* <div className="bg-gray-800 p-6 rounded-xl shadow">
<div className="flex items-center justify-between">
<p className="text-gray-400 font-medium">{t("Member Level")}</p>
<User className="text-purple-400 w-6 h-6" />
</div>
<p className="text-2xl font-bold text-purple-400 mt-3">
{mockUserData.memberLevel}
</p>
<p className="text-sm text-gray-500 mt-1">{t("Loyalty status")}</p>
</div> */}
</div>
{/* Main Content */}
<div className="bg-gray-800 rounded-xl shadow">
{/* Tabs */}
@@ -290,82 +175,114 @@ export default function FinanceDetailUser() {
<h2 className="text-xl font-bold mb-4">
{t("Booking History")}
</h2>
{mockUserPurchases.map((purchase) => (
<div
key={purchase.id}
className="bg-gray-700 p-6 rounded-lg hover:bg-gray-600 transition-colors"
>
<div className="flex justify-between items-start mb-4">
<div className="bg-gray-700 p-6 rounded-lg hover:bg-gray-600 transition-colors">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-lg font-bold text-gray-100">
{data?.departure}
</h3>
</div>
<div className="flex items-center gap-3">
{data && getStatusBadge(data?.order_status)}
{data && (
<Select
onValueChange={(
value:
| "pending_payment"
| "pending_confirmation"
| "cancelled"
| "confirmed"
| "completed",
) =>
mutate({
id: data.id,
body: { order_status: value },
})
}
value={data.order_status}
>
<SelectTrigger className="w-[180px] bg-gray-800 border-gray-700 text-gray-200">
<SelectValue placeholder={t("Statusni tanlang")} />
</SelectTrigger>
<SelectContent className="bg-gray-800 text-gray-100 border-gray-700">
<SelectItem value="pending_payment">
{t("Pending")}
</SelectItem>
<SelectItem value="pending_confirmation">
{t("Paid")}
</SelectItem>
<SelectItem value="confirmed">
{t("Refunded")}
</SelectItem>
<SelectItem value="completed">
{t("Completed")}
</SelectItem>
<SelectItem value="cancelled">
{t("Cancelled")}
</SelectItem>
</SelectContent>
</Select>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-gray-400" />
<div>
<h3 className="text-lg font-bold text-gray-100">
{purchase.tourName}
</h3>
<p className="text-gray-400 text-sm">
{t("Booking Ref")}: {purchase.bookingReference}
<p className="text-sm text-gray-400">
{t("Destination")}
</p>
<p className="text-gray-100">{data?.destination}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-gray-400" />
<div>
<p className="text-sm text-gray-400">
{t("Travel Dates")}
</p>
<p className="text-gray-100">
{data?.departure_date} - {data?.arrival_time}
</p>
</div>
{getStatusBadge(purchase.paymentStatus)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-gray-400" />
<div>
<p className="text-sm text-gray-400">
{t("Destination")}
</p>
<p className="text-gray-100">
{purchase.destination}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-gray-400" />
<div>
<p className="text-sm text-gray-400">
{t("Travel Dates")}
</p>
<p className="text-gray-100">
{purchase.travelDate} - {purchase.returnDate}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-gray-400" />
<div>
<p className="text-sm text-gray-400">
{t("Travelers")}
</p>
<p className="text-gray-100">{purchase.travelers}</p>
</div>
</div>
<div className="flex items-center gap-2">
<DollarSign className="w-4 h-4 text-gray-400" />
<div>
<p className="text-sm text-gray-400">{t("Amount")}</p>
<p className="text-green-400 font-bold">
{formatPrice(purchase.amount, true)}
</p>
</div>
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-gray-400" />
<div>
<p className="text-sm text-gray-400">
{t("Travelers")}
</p>
<p className="text-gray-100">
{data?.participant.length}
</p>
</div>
</div>
<div className="flex justify-between items-center pt-4 border-t border-gray-600">
<div className="text-sm text-gray-400">
{t("Booked on")} {purchase.purchaseDate}{" "}
{getPaymentMethod(purchase.paymentMethod)}
<div className="flex items-center gap-2">
<DollarSign className="w-4 h-4 text-gray-400" />
<div>
<p className="text-sm text-gray-400">{t("Amount")}</p>
<p className="text-green-400 font-bold">
{data && formatPrice(data?.total_price, true)}
</p>
</div>
</div>
</div>
))}
<div className="flex justify-between items-center pt-4 border-t border-gray-600">
<div className="text-sm text-gray-400">
{t("Booked on")} {data?.departure_date}
</div>
</div>
</div>
</div>
)}
{activeTab === "details" && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="grid grid-cols-1 gap-8">
{/* Personal Information */}
<div>
<h3 className="text-lg font-bold mb-4">
@@ -378,7 +295,9 @@ export default function FinanceDetailUser() {
<p className="text-sm text-gray-400">
{t("Full Name")}
</p>
<p className="text-gray-100">{mockUserData.userName}</p>
<p className="text-gray-100">
{data?.user.first_name} {data?.user.last_name}
</p>
</div>
</div>
@@ -389,71 +308,113 @@ export default function FinanceDetailUser() {
{t("Phone Number")}
</p>
<p className="text-gray-100">
{formatPhone(mockUserData.userPhone)}
{data && formatPhone(data?.user.phone)}
</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
<Mail className="w-5 h-5 text-yellow-400" />
<div>
<p className="text-sm text-gray-400">
{t("Email Address")}
</p>
<p className="text-gray-100">
{mockUserData.userEmail}
</p>
{data?.user.email && (
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
<Mail className="w-5 h-5 text-yellow-400" />
<div>
<p className="text-sm text-gray-400">
{t("Email Address")}
</p>
<p className="text-gray-100">{data?.user.email}</p>
</div>
</div>
</div>
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
<Calendar className="w-5 h-5 text-purple-400" />
<div>
<p className="text-sm text-gray-400">
{t("Member Since")}
</p>
<p className="text-gray-100">{mockUserData.joinDate}</p>
</div>
</div>
)}
</div>
</div>
{/* Travel Preferences */}
<div>
<h3 className="text-lg font-bold mb-4">
{t("Travel Statistics")}
</h3>
<div className="space-y-4">
<div className="p-4 bg-gray-700 rounded-lg">
<p className="text-sm text-gray-400 mb-2">
{t("Favorite Destination")}
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6">
<h2 className="text-lg font-semibold text-slate-100 mb-4 flex items-center gap-2">
<UsersIcon className="w-5 h-5 text-purple-400" />
{t("Hamrohlar")}
<span className="ml-auto text-sm font-normal text-slate-500">
{data?.participant.length} ta
</span>
</h2>
<div className="space-y-4 max-h-[300px] overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-slate-700 hover:scrollbar-thumb-slate-600 scrollbar-track-transparent">
{data && data?.participant.length > 0 ? (
data?.participant.map((companion, index) => (
<div
key={index}
className="p-4 bg-slate-800/50 rounded-xl border border-slate-700/50"
>
<div className="flex items-start gap-4">
<div className="w-14 h-14 rounded-lg bg-gradient-to-br from-purple-600 to-pink-600 flex items-center justify-center flex-shrink-0 shadow-lg shadow-purple-500/20">
<User className="w-7 h-7 text-white" />
</div>
<div className="flex-1 space-y-3">
<div>
<h3 className="font-semibold text-slate-100 text-lg">
{companion.first_name} {companion.last_name}
</h3>
<div className="flex items-center gap-2 mt-1">
<span
className={`px-2 py-1 text-xs rounded-full border ${
companion.gender === "male"
? "bg-blue-500/20 text-blue-400 border-blue-500/30"
: "bg-pink-500/20 text-pink-400 border-pink-500/30"
}`}
>
{companion.gender === "male"
? t("Erkak")
: t("Ayol")}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-xs text-slate-500">
{t("Tug'ilgan sana")}
</p>
<p className="text-slate-200 font-medium">
{companion.birth_date}
</p>
</div>
<div>
<p className="text-xs text-slate-500">
{t("Telefon raqami")}
</p>
<p className="text-slate-200 font-medium">
{formatPhone(companion.phone_number)}
</p>
</div>
</div>
{companion.participant_pasport_image.length >
0 && (
<div>
<p className="text-xs text-slate-500 mb-2">
{t("Passport rasmlari")}:
</p>
<div className="flex gap-2">
{companion.participant_pasport_image.map(
(img) => (
<div
key={index}
className="w-20 h-20 rounded-lg bg-slate-700/50 flex items-center justify-center overflow-hidden border border-slate-600/50"
>
<img
alt="passport image"
src={img.image}
className="w-full h-full"
/>
</div>
),
)}
</div>
</div>
)}
</div>
</div>
</div>
))
) : (
<p className="text-sm text-slate-500 text-center py-4">
{t("Hozircha hamrohlar qo'shilmagan")}
</p>
<p className="text-gray-100 font-medium">Dubai, UAE</p>
<p className="text-sm text-gray-400 mt-1">
2 {t("bookings")}
</p>
</div>
<div className="p-4 bg-gray-700 rounded-lg">
<p className="text-sm text-gray-400 mb-2">
{t("Preferred Agency")}
</p>
<p className="text-gray-100 font-medium">
Silk Road Travel
</p>
<p className="text-sm text-gray-400 mt-1">
2 {t("out of")} 3 {t("bookings")}
</p>
</div>
<div className="p-4 bg-gray-700 rounded-lg">
<p className="text-sm text-gray-400 mb-2">
{t("Average Booking Value")}
</p>
<p className="text-green-400 font-bold">
{formatPrice(totalSpent, true)}
</p>
</div>
)}
</div>
</div>
</div>

View File

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

View File

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

View File

@@ -1,6 +1,9 @@
import z from "zod";
const fileSchema = z.instanceof(File, { message: "Rasm faylini yuklang" });
const fileSchema = z.union([
z.instanceof(File, { message: "Rasm faylini yuklang" }),
z.string().min(1, { message: "Rasm faylini yuklang" }),
]);
export const newsForm = z.object({
title: z.string().min(2, {

View File

@@ -82,5 +82,42 @@ export interface GetDetailNewsCategory {
id: number;
name: string;
name_ru: string;
name_uz: string;
};
}
export interface NewsDetail {
status: boolean;
data: {
id: number;
title: string;
title_ru: string;
title_uz: string;
image: string;
text: string;
text_ru: string;
text_uz: string;
is_public: boolean;
category: {
name: string;
name_ru: string;
name_uz: string;
};
tag: [
{
id: number;
name: string;
name_ru: string;
name_uz: string;
},
];
post_images: [
{
image: string;
text: string;
text_ru: string;
text_uz: string;
},
];
};
}

View File

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

View File

@@ -1,5 +1,5 @@
"use client";
import { getAllNews } from "@/pages/news/lib/api";
import { deleteNews, getAllNews } from "@/pages/news/lib/api";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
@@ -10,27 +10,56 @@ import {
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import clsx from "clsx";
import { Edit, FolderOpen, PlusCircle, Trash2 } from "lucide-react";
import {
ChevronLeft,
ChevronRight,
Edit,
FolderOpen,
Loader2,
PlusCircle,
Trash2,
} from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
const News = () => {
const [currentPage, setCurrentPage] = useState(1);
const { t } = useTranslation();
const [deleteId, setDeleteId] = useState<number | null>(null);
const queryClient = useQueryClient();
const navigate = useNavigate();
const {
data: allNews,
isLoading,
isError,
} = useQuery({
queryKey: ["all_news"],
queryFn: () => getAllNews({ page: 1, page_size: 2 }),
queryKey: ["all_news", currentPage],
queryFn: () => getAllNews({ page: currentPage, page_size: 10 }),
});
const confirmDelete = () => {};
const { mutate, isPending } = useMutation({
mutationFn: (id: number) => deleteNews(id),
onSuccess: () => {
setDeleteId(null);
queryClient.refetchQueries({ queryKey: ["all_news"] });
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
richColors: true,
position: "top-center",
});
},
});
const confirmDelete = () => {
if (deleteId) {
mutate(deleteId);
}
};
if (isLoading) {
return (
@@ -157,7 +186,7 @@ const News = () => {
{/* Actions */}
<div className="flex justify-end gap-2 pt-3">
<Button
onClick={() => navigate(`/news/add`)}
onClick={() => navigate(`/news/edit/${item.id}`)}
size="sm"
variant="outline"
className="hover:bg-neutral-700 hover:text-blue-400"
@@ -201,11 +230,51 @@ const News = () => {
</Button>
<Button variant="destructive" onClick={confirmDelete}>
<Trash2 className="w-4 h-4 mr-2" />
{t("O'chirish")}
{isPending ? (
<Loader2 className="animate-spin" />
) : (
t("O'chirish")
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="flex justify-end gap-2">
<button
disabled={currentPage === 1}
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all hover:border-slate-500"
>
<ChevronLeft className="w-5 h-5" />
</button>
{[...Array(allNews?.data.data.total_pages)].map((_, i) => (
<button
key={i}
onClick={() => setCurrentPage(i + 1)}
className={`px-4 py-2 rounded-lg border transition-all font-medium ${
currentPage === i + 1
? "bg-gradient-to-r from-blue-600 to-cyan-600 border-blue-500 text-white shadow-lg shadow-cyan-500/50"
: "border-slate-600 text-slate-300 hover:bg-slate-700/50 hover:border-slate-500"
}`}
>
{i + 1}
</button>
))}
<button
disabled={currentPage === allNews?.data.data.total_pages}
onClick={() =>
setCurrentPage((p) =>
Math.min(p + 1, allNews ? allNews?.data.data.total_pages : 0),
)
}
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all hover:border-slate-500"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
);
};

View File

@@ -86,7 +86,7 @@ const NewsCategory = () => {
useEffect(() => {
if (detail) {
form.setValue("title", detail.data.data.name);
form.setValue("title", detail.data.data.name_uz);
form.setValue("title_ru", detail.data.data.name_ru);
}
}, [editItem, form, detail]);

View File

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

View File

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

40
src/pages/seo/lib/api.ts Normal file
View File

@@ -0,0 +1,40 @@
import type { AllSeoData, DetailSeoData } from "@/pages/seo/lib/types";
import httpClient from "@/shared/config/api/httpClient";
import { SITE_SEO } from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios";
const createSeo = async (body: FormData) => {
const res = await httpClient.post(SITE_SEO, body, {
headers: { "Content-Type": "multipart/form-data" },
});
return res;
};
const getAllSeo = async (params: {
page: number;
page_size: number;
}): Promise<AxiosResponse<AllSeoData>> => {
const res = await httpClient.get(SITE_SEO, { params });
return res;
};
const getDetailSeo = async (
id: number,
): Promise<AxiosResponse<DetailSeoData>> => {
const res = await httpClient.get(`${SITE_SEO}${id}/`);
return res;
};
const updateSeo = async ({ body, id }: { id: number; body: FormData }) => {
const res = await httpClient.patch(`${SITE_SEO}${id}/`, body, {
headers: { "Content-Type": "multipart/form-data" },
});
return res;
};
const deleteSeo = async ({ id }: { id: number }) => {
const res = await httpClient.delete(`${SITE_SEO}${id}/`);
return res;
};
export { createSeo, deleteSeo, getAllSeo, getDetailSeo, updateSeo };

View File

@@ -0,0 +1,37 @@
export interface AllSeoData {
status: boolean;
data: {
links: {
previous: string;
next: string;
};
total_items: number;
total_pages: number;
page_size: number;
current_page: number;
results: [
{
id: number;
title: string;
description: string;
keywords: string;
og_title: string;
og_description: string;
og_image: string;
},
];
};
}
export interface DetailSeoData {
status: boolean;
data: {
id: number;
title: string;
description: string;
keywords: string;
og_title: string;
og_description: string;
og_image: string;
};
}

View File

@@ -1,13 +1,26 @@
"use client";
import {
createSeo,
deleteSeo,
getAllSeo,
getDetailSeo,
updateSeo,
} from "@/pages/seo/lib/api";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle,
FileText,
Image as ImageIcon,
Loader2,
Pencil,
Trash2,
TrendingUp,
} from "lucide-react";
import { useState, type ChangeEvent } from "react";
import { useEffect, useState, type ChangeEvent } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
type SeoData = {
title: string;
@@ -15,7 +28,7 @@ type SeoData = {
keywords: string;
ogTitle: string;
ogDescription: string;
ogImage: string;
ogImage: File | null | string;
};
export default function Seo() {
@@ -25,10 +38,107 @@ export default function Seo() {
keywords: "",
ogTitle: "",
ogDescription: "",
ogImage: "",
ogImage: null,
});
const { t } = useTranslation();
const [edit, setEdit] = useState<number | null>(null);
const queryClient = useQueryClient();
const { mutate, isPending } = useMutation({
mutationFn: async (body: FormData) => createSeo(body),
onSuccess: () => {
toast.success(t("Malumotlar muvaffaqiyatli saqlandi"), {
position: "top-center",
});
setFormData({
description: "",
keywords: "",
ogDescription: "",
ogImage: null,
ogTitle: "",
title: "",
});
queryClient.refetchQueries({ queryKey: ["seo_all"] });
queryClient.refetchQueries({ queryKey: ["seo_detail"] });
setImagePreview(null);
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
position: "top-center",
richColors: true,
});
},
});
const { mutate: editSeo, isPending: editPending } = useMutation({
mutationFn: async ({ body, id }: { body: FormData; id: number }) =>
updateSeo({ body, id }),
onSuccess: () => {
toast.success(t("Malumotlar muvaffaqiyatli saqlandi"), {
position: "top-center",
});
setEdit(null);
setFormData({
description: "",
keywords: "",
ogDescription: "",
ogImage: null,
ogTitle: "",
title: "",
});
queryClient.refetchQueries({ queryKey: ["seo_all"] });
queryClient.refetchQueries({ queryKey: ["seo_detail"] });
setImagePreview(null);
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
position: "top-center",
richColors: true,
});
},
});
const { mutate: deleteSeoo } = useMutation({
mutationFn: (id: number) => deleteSeo({ id }),
onSuccess: () => {
setEdit(null);
setFormData({
description: "",
keywords: "",
ogDescription: "",
ogImage: null,
ogTitle: "",
title: "",
});
queryClient.refetchQueries({ queryKey: ["seo_all"] });
queryClient.refetchQueries({ queryKey: ["seo_detail"] });
setImagePreview(null);
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
position: "top-center",
richColors: true,
});
},
});
const { data: allSeo, isLoading } = useQuery({
queryKey: ["seo_all"],
queryFn: () => getAllSeo({ page: 1, page_size: 99 }),
select(data) {
return data.data.data.results;
},
});
const { data: detailSeo } = useQuery({
queryKey: ["seo_detail", edit],
queryFn: () => getDetailSeo(edit!),
select(data) {
return data.data.data;
},
enabled: !!edit,
});
const [savedSeo, setSavedSeo] = useState<SeoData | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const handleChange = (
@@ -44,37 +154,59 @@ export default function Seo() {
const handleImageUpload = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
const result = event.target?.result as string;
setImagePreview(result);
setFormData((prev) => ({
...prev,
ogImage: result,
}));
};
reader.readAsDataURL(file);
setFormData((prev) => ({ ...prev, ogImage: file }));
setImagePreview(URL.createObjectURL(file));
}
};
const handleSave = () => {
setSavedSeo(formData);
setFormData({
description: "",
keywords: "",
ogDescription: "",
ogImage: "",
ogTitle: "",
title: "",
});
const handleEdit = (id: number) => {
setEdit(id);
};
const getTitleLength = () => formData.title.length;
const getDescriptionLength = () => formData.description.length;
const handleDelete = (id: number) => {
deleteSeoo(id);
};
const isValidTitle = getTitleLength() > 30 && getTitleLength() <= 60;
useEffect(() => {
if (detailSeo) {
setFormData({
description: detailSeo.description,
keywords: detailSeo.keywords,
ogDescription: detailSeo.og_description,
ogImage: detailSeo.og_image,
ogTitle: detailSeo.og_title,
title: detailSeo.title,
});
setImagePreview(detailSeo.og_image || null);
}
}, [detailSeo, edit]);
const handleSave = () => {
const form = new FormData();
form.append("title", formData.title);
form.append("description", formData.description);
form.append("keywords", formData.keywords);
form.append("og_title", formData.ogTitle);
form.append("og_description", formData.ogDescription);
// faqat File bolsa qoshamiz
if (formData.ogImage instanceof File) {
form.append("og_image", formData.ogImage);
}
if (edit) {
editSeo({ body: form, id: edit });
} else {
mutate(form);
}
};
const getTitleLength = () => formData.title?.length ?? 0;
const getDescriptionLength = () => formData.description?.length ?? 0;
const isValidTitle = getTitleLength() > 10 && getTitleLength() <= 60;
const isValidDescription =
getDescriptionLength() > 120 && getDescriptionLength() <= 160;
getDescriptionLength() > 60 && getDescriptionLength() <= 160;
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 p-8 w-full">
@@ -83,33 +215,32 @@ export default function Seo() {
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<TrendingUp className="w-8 h-8 text-blue-400" />
<h1 className="text-4xl font-bold text-white">SEO Manager</h1>
<h1 className="text-4xl font-bold text-white">
{t("SEO Manager")}
</h1>
</div>
<p className="text-slate-400">
Saytingizni qidiruv tizimida yaxshi pozitsiyaga keltiring
{t("Saytingizni qidiruv tizimida yaxshi pozitsiyaga keltiring")}
</p>
</div>
{/* Main Content */}
{/* Main Form */}
<div className="grid grid-cols-1 gap-8">
<div className="bg-slate-800 rounded-lg p-6 space-y-6">
{/* Title */}
<div>
<label className="block text-sm font-semibold text-white mb-2">
<FileText className="inline w-4 h-4 mr-1" /> Page Title
<FileText className="inline w-4 h-4 mr-1" /> {t("Page Title")}
</label>
<input
type="text"
name="title"
value={formData.title}
onChange={handleChange}
placeholder="Sahifa sarlavhasi (3060 belgi)"
placeholder={t("Sahifa sarlavhasi (3060 belgi)")}
className="w-full bg-slate-700 text-white px-4 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<div className="flex items-center justify-between mt-2">
<span className="text-sm text-slate-400">
{getTitleLength()} / 60
</span>
{isValidTitle && (
<CheckCircle className="w-5 h-5 text-green-400" />
)}
@@ -122,13 +253,13 @@ export default function Seo() {
{/* Description */}
<div>
<label className="block text-sm font-semibold text-white mb-2">
Meta Description
{t("Meta Description")}
</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
placeholder="Sahifa tavsifi (120160 belgi)"
placeholder={t("Sahifa tavsifi (120160 belgi)")}
className="w-full bg-slate-700 text-white px-4 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
<div className="flex items-center justify-between mt-2">
@@ -147,58 +278,59 @@ export default function Seo() {
{/* Keywords */}
<div>
<label className="block text-sm font-semibold text-white mb-2">
Keywords
{t("Keywords")}
</label>
<input
type="text"
name="keywords"
value={formData.keywords}
onChange={handleChange}
placeholder="Kalit so'zlar (vergul bilan ajratilgan)"
placeholder={t("Kalit so'zlar (vergul bilan ajratilgan)")}
className="w-full bg-slate-700 text-white px-4 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p className="text-xs text-slate-400 mt-1">
Masalan: Python, Web Development, Coding
{t("Masalan: Python, Web Development, Coding")}
</p>
</div>
{/* OG Tags */}
<div className="border-t border-slate-700 pt-6">
<h3 className="text-sm font-semibold text-white mb-4">
Open Graph (Ijtimoiy Tarmoqlar)
{t("Open Graph (Ijtimoiy Tarmoqlar)")}
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm text-slate-300 mb-2">
OG Title
{t("OG Title")}
</label>
<input
type="text"
name="ogTitle"
value={formData.ogTitle}
onChange={handleChange}
placeholder="Ijtimoiy tarmoqdagi sarlavha"
placeholder={t("Ijtimoiy tarmoqdagi sarlavha")}
className="w-full bg-slate-700 text-white px-4 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm text-slate-300 mb-2">
OG Description
{t("OG Description")}
</label>
<textarea
name="ogDescription"
value={formData.ogDescription}
onChange={handleChange}
placeholder="Ijtimoiy tarmoqdagi tavsif"
placeholder={t("Ijtimoiy tarmoqdagi tavsif")}
className="w-full bg-slate-700 text-white px-4 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
<div>
<label className="block text-sm text-slate-300 mb-2">
<ImageIcon className="inline w-4 h-4 mr-1" /> OG Image
<ImageIcon className="inline w-4 h-4 mr-1" />{" "}
{t("OG Image")}
</label>
<div className="space-y-3">
<input
@@ -220,12 +352,12 @@ export default function Seo() {
setImagePreview(null);
setFormData((prev) => ({
...prev,
ogImage: "",
ogImage: null,
}));
}}
className="mt-2 text-xs bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded"
>
Ochirish
{t("O'chirish")}
</button>
</div>
)}
@@ -236,33 +368,73 @@ export default function Seo() {
<button
onClick={handleSave}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 rounded-lg transition-colors"
disabled={isPending}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
>
Saqlash
{isPending || editPending ? (
<Loader2 className="animate-spin mx-auto" />
) : edit ? (
t("Tahrirlash")
) : (
t("Saqlash")
)}
</button>
</div>
</div>
{/* Saved SEO Data (Preview) */}
{savedSeo && (
<div className="mt-8 bg-slate-700 rounded-lg p-6 text-slate-200">
<h3 className="text-lg font-semibold mb-2">
Saqlangan SEO Malumotlari
</h3>
<pre className="bg-slate-800 p-4 rounded text-xs overflow-auto">
{JSON.stringify(
{
...savedSeo,
ogImage: savedSeo.ogImage
? savedSeo.ogImage.substring(0, 100) + "..."
: "",
},
null,
2,
)}
</pre>
</div>
)}
{/* Saved SEO Data */}
<div className="mt-8 bg-slate-700 rounded-lg p-6 text-slate-200">
<h3 className="text-lg font-semibold mb-4">
{t("Saqlangan SEO Malumotlari")}
</h3>
{isLoading ? (
<p>{t("Yuklanmoqda...")}</p>
) : allSeo && allSeo.length > 0 ? (
<div className="space-y-4 max-h-[400px] overflow-y-auto">
{allSeo.map((seo) => (
<div
key={seo.id}
className="bg-slate-800 p-4 rounded-lg border border-slate-600 relative"
>
<div className="absolute top-2 right-2 flex gap-2">
<button
onClick={() => handleEdit(seo.id)}
className="p-1 text-blue-400 hover:text-blue-600"
>
<Pencil size={18} />
</button>
<button
onClick={() => handleDelete(seo.id)}
className="p-1 text-red-400 hover:text-red-600"
>
<Trash2 size={18} />
</button>
</div>
<h4 className="text-white font-semibold">{seo.title}</h4>
<p className="text-sm text-slate-300">
{seo.description?.substring(0, 150)}...
</p>
<p className="text-xs text-slate-400">
<strong>{t("Keywords")}:</strong> {seo.keywords}
</p>
{seo.og_image && (
<img
src={seo.og_image}
alt="OG"
className="w-full h-40 object-cover rounded-lg mt-2"
/>
)}
</div>
))}
</div>
) : (
<p className="text-slate-400">
{t("Hozircha SEO malumotlari mavjud emas.")}
</p>
)}
</div>
</div>
</div>
);

View File

@@ -0,0 +1,47 @@
import type {
AllGetBanner,
DetailGetBanner,
} from "@/pages/site-banner/lib/types";
import httpClient from "@/shared/config/api/httpClient";
import { BANNER } from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios";
const getBanner = async (params: {
page: number;
page_size: number;
}): Promise<AxiosResponse<AllGetBanner>> => {
const res = await httpClient.get(BANNER, { params });
return res;
};
const getBannerDetail = async (
id: number,
): Promise<AxiosResponse<DetailGetBanner>> => {
const res = await httpClient.get(`${BANNER}${id}/`);
return res;
};
const bannerDelete = async (id: number) => {
const res = await httpClient.delete(`${BANNER}${id}/`);
return res;
};
const createBanner = async (body: FormData) => {
const res = await httpClient.post(BANNER, body, {
headers: {
"Content-Type": "multipart/form-data",
},
});
return res;
};
const updateBanner = async ({ body, id }: { id: number; body: FormData }) => {
const res = await httpClient.patch(`${BANNER}${id}/`, body, {
headers: {
"Content-Type": "multipart/form-data",
},
});
return res;
};
export { bannerDelete, createBanner, getBanner, getBannerDetail, updateBanner };

View File

@@ -0,0 +1,37 @@
export interface DetailGetBanner {
status: boolean;
data: {
id: number;
title: string;
title_ru: string;
title_uz: string;
description: string;
description_ru: string;
description_uz: string;
image: string;
link: string;
position: "banner1" | "banner2" | "banner3" | "banner4";
};
}
export interface AllGetBanner {
status: boolean;
data: {
links: {
previous: string;
next: string;
};
total_items: number;
total_pages: number;
page_size: number;
current_page: number;
results: {
id: number;
title: string;
description: string;
image: string;
link: string;
position: "banner1" | "banner2" | "banner3" | "banner4";
}[];
};
}

View File

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

View File

@@ -0,0 +1,112 @@
"use client";
import { getBanner } from "@/pages/site-banner/lib/api";
import { Card } from "@/shared/ui/card";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/shared/ui/carousel";
import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, Loader2, MoveRightIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
const BannerCarousel = () => {
const { t } = useTranslation();
// 🧠 Bannerlarni backenddan olish
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["all_banner"],
queryFn: () => getBanner(),
select: (res) =>
res.data.data.results.filter((b) => b.position === "banner1"),
});
const colors = ["#EDF5C7", "#F5DCC7"];
if (isLoading)
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="animate-spin text-blue-500 w-8 h-8" />
</div>
);
if (isError)
return (
<div className="flex flex-col items-center justify-center min-h-[400px] text-white">
<AlertTriangle className="text-red-500 w-8 h-8 mb-2" />
<p>{t("Ma'lumotlarni yuklashda xatolik yuz berdi.")}</p>
<button
onClick={() => refetch()}
className="mt-3 bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg"
>
{t("Qayta urinish")}
</button>
</div>
);
if (!data || data.length === 0)
return (
<div className="flex items-center justify-center text-gray-400 min-h-[400px]">
{t("Hozircha bannerlar mavjud emas")}
</div>
);
return (
<div className="mt-10 max-lg:hidden custom-container">
<Carousel opts={{ loop: true, align: "start" }}>
<CarouselContent>
{data.map((banner, index) => (
<CarouselItem
key={banner.id}
className="basis-full md:basis-[80%] shrink-0"
>
<div className="h-[500px]">
<Card className="h-full !rounded-[50px] flex border-none items-center justify-start relative overflow-hidden">
{/* <BannerCircle
color={colors[index % colors.length]}
className="w-[60%] h-full absolute z-10"
/> */}
{/* Matn qismi */}
<div className="flex flex-col gap-6 w-96 z-20 absolute left-14 top-1/2 -translate-y-1/2">
<p className="text-4xl font-semibold text-[#232325]">
{banner.title}
</p>
<p className="text-[#212122] font-medium">
{banner.description}
</p>
<Link
to={banner.link || "#"}
className="bg-white text-[#212122] font-semibold flex gap-4 px-8 py-4 shadow-sm !rounded-4xl w-fit"
>
<p>{t("Batafsil")}</p>
<MoveRightIcon />
</Link>
</div>
{/* Rasm qismi */}
<div className="absolute right-0 w-[50%] h-full">
<img
src={banner.image}
alt={banner.title}
className="object-cover w-full h-full"
/>
</div>
</Card>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="absolute left-2 top-1/2 -translate-y-1/2 bg-white/80 hover:bg-white p-2 size-10 rounded-full shadow z-10" />
<CarouselNext className="absolute right-2 top-1/2 -translate-y-1/2 bg-white/80 hover:bg-white p-2 rounded-full size-10 shadow z-10" />
</Carousel>
</div>
);
};
export default BannerCarousel;

View File

@@ -0,0 +1,122 @@
import type {
GetAllHelpPage,
GetAllOfferta,
GetDetailHelpPage,
GetDetailOfferta,
} from "@/pages/site-page/lib/types";
import httpClient from "@/shared/config/api/httpClient";
import { HELP_PAGE, OFFERTA } from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios";
const createOfferta = async ({
body,
}: {
body: {
title: string;
content: string;
person_type: "individual" | "legal_entity";
is_active: boolean;
};
}) => {
const res = await httpClient.post(OFFERTA, body);
return res;
};
const updateOfferta = async ({
body,
id,
}: {
id: number;
body: {
title?: string;
content?: string;
person_type?: "individual" | "legal_entity";
is_active?: boolean;
};
}) => {
const res = await httpClient.patch(`${OFFERTA}${id}/`, body);
return res;
};
const getAllOfferta = async (params: {
page: number;
page_size: number;
}): Promise<AxiosResponse<GetAllOfferta>> => {
const res = await httpClient.get(OFFERTA, { params });
return res;
};
const getOneOfferta = async (
id: number,
): Promise<AxiosResponse<GetDetailOfferta>> => {
const res = await httpClient.get(`${OFFERTA}${id}/`);
return res;
};
const deleteOfferta = async (id: number) => {
const res = await httpClient.delete(`${OFFERTA}${id}/`);
return res;
};
const createHelpPage = async ({
body,
}: {
body: {
title: string;
content: string;
page_type: "privacy_policy" | "user_agreement";
is_active: boolean;
};
}) => {
const res = await httpClient.post(HELP_PAGE, body);
return res;
};
const getAllHelpPage = async (params: {
page: number;
page_size: number;
}): Promise<AxiosResponse<GetAllHelpPage>> => {
const res = await httpClient.get(HELP_PAGE, { params });
return res;
};
const getDetailHelpPage = async (
id: number,
): Promise<AxiosResponse<GetDetailHelpPage>> => {
const res = await httpClient.get(`${HELP_PAGE}${id}/`);
return res;
};
const updateHelpPage = async ({
body,
id,
}: {
id: number;
body: {
title?: string;
content?: string;
page_type?: "privacy_policy" | "user_agreement";
is_active?: boolean;
};
}) => {
const res = await httpClient.patch(`${HELP_PAGE}${id}/`, body);
return res;
};
const deleteHelpPage = async (id: number) => {
const res = await httpClient.delete(`${HELP_PAGE}${id}/`);
return res;
};
export {
createHelpPage,
createOfferta,
deleteHelpPage,
deleteOfferta,
getAllHelpPage,
getAllOfferta,
getDetailHelpPage,
getOneOfferta,
updateHelpPage,
updateOfferta,
};

View File

@@ -0,0 +1,63 @@
export interface GetAllOfferta {
status: boolean;
data: {
links: {
previous: string;
next: string;
};
total_items: number;
total_pages: number;
page_size: number;
current_page: number;
results: {
id: number;
title: string;
content: string;
person_type: "individual" | "legal_entity";
is_active: boolean;
}[];
};
}
export interface GetDetailOfferta {
status: boolean;
data: {
id: number;
title: string;
content: string;
person_type: "individual" | "legal_entity";
is_active: boolean;
};
}
export interface GetAllHelpPage {
status: boolean;
data: {
links: {
previous: string;
next: string;
};
total_items: number;
total_pages: number;
page_size: number;
current_page: number;
results: {
id: number;
title: string;
content: string;
page_type: "privacy_policy" | "user_agreement";
is_active: boolean;
}[];
};
}
export interface GetDetailHelpPage {
status: true;
data: {
id: number;
title: string;
content: string;
page_type: "privacy_policy" | "user_agreement";
is_active: true;
};
}

View File

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

View File

@@ -1,6 +1,12 @@
import {
createOfferta,
deleteOfferta,
getAllOfferta,
getOneOfferta,
updateOfferta,
} from "@/pages/site-page/lib/api";
import { Button } from "@/shared/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
import { Checkbox } from "@/shared/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -16,47 +22,25 @@ import {
SelectTrigger,
SelectValue,
} from "@/shared/ui/select";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Edit2, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import ReactQuill from "react-quill-new";
import "react-quill-new/dist/quill.snow.css";
type Offer = {
id: string;
title: string;
audience: "Jismoniy shaxslar" | "Yuridik shaxslar";
content: string;
active: boolean;
createdAt: string;
};
const FAKE_DATA: Offer[] = [
{
id: "of-1",
title: "Ommaviy oferta - Standart shartlar",
audience: "Jismoniy shaxslar",
content:
"Bu hujjat kompaniya va xizmatlardan foydalanish bo'yicha umumiy shartlarni o'z ichiga oladi.",
active: true,
createdAt: new Date().toISOString(),
},
{
id: "of-2",
title: "Yuridik shaxslar uchun oferta",
audience: "Yuridik shaxslar",
content: "Yuridik shaxslar uchun maxsus shartlar va kafolatlar.",
active: false,
createdAt: new Date(Date.now() - 86400000).toISOString(),
},
];
const STORAGE_KEY = "ommaviy_oferta_v1";
import { toast } from "sonner";
export default function OmmaviyOfertaCRUD() {
const [items, setItems] = useState<Offer[]>([]);
const [query, setQuery] = useState("");
const [editing, setEditing] = useState<Offer | null>(null);
const [form, setForm] = useState<Partial<Offer>>({
const { t } = useTranslation();
const [deleteOpen, setDeleteOpen] = useState<boolean>(false);
const queryClient = useQueryClient();
const [editing, setEditing] = useState<number | null>(null);
const [form, setForm] = useState<{
title: string;
content: string;
audience: string;
active: boolean;
}>({
title: "",
audience: "Jismoniy shaxslar",
content: "",
@@ -64,24 +48,6 @@ export default function OmmaviyOfertaCRUD() {
});
const [errors, setErrors] = useState<Record<string, string>>({});
useEffect(() => {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
try {
const parsed = JSON.parse(raw) as Offer[];
setItems(parsed);
} catch {
setItems(FAKE_DATA);
}
} else {
setItems(FAKE_DATA);
}
}, []);
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
}, [items]);
function resetForm() {
setForm({
title: "",
@@ -93,100 +59,214 @@ export default function OmmaviyOfertaCRUD() {
setEditing(null);
}
function validate(f: Partial<Offer>) {
const e: Record<string, string> = {};
if (!f.title || f.title.trim().length < 3)
e.title = "Sarlavha kamida 3 ta belgidan iborat bo'lishi kerak";
if (!f.content || f.content.trim().length < 10)
e.content = "Kontent kamida 10 ta belgidan iborat bo'lishi kerak";
return e;
}
const { mutate: create } = useMutation({
mutationFn: (body: {
title: string;
content: string;
person_type: "individual" | "legal_entity";
is_active: boolean;
}) => createOfferta({ body }),
onSuccess: () => {
resetForm();
queryClient.refetchQueries({ queryKey: ["all_offerta"] });
queryClient.refetchQueries({ queryKey: ["detail_offerta"] });
toast.success(t("Muvaffaqiyatli yaratildi"), {
position: "top-center",
richColors: true,
});
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
position: "top-center",
richColors: true,
});
},
});
const { mutate: update } = useMutation({
mutationFn: ({
body,
id,
}: {
body: {
title?: string;
content?: string;
person_type?: "individual" | "legal_entity";
is_active?: boolean;
};
id: number;
}) => updateOfferta({ body, id }),
onSuccess: () => {
resetForm();
setEditing(null);
queryClient.refetchQueries({ queryKey: ["all_offerta"] });
queryClient.refetchQueries({ queryKey: ["detail_offerta"] });
toast.success(t("Muvaffaqiyatli yangilandi"), {
position: "top-center",
richColors: true,
});
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
position: "top-center",
richColors: true,
});
},
});
const { mutate: removeOfferta } = useMutation({
mutationFn: ({ id }: { id: number }) => deleteOfferta(id),
onSuccess: () => {
resetForm();
queryClient.refetchQueries({ queryKey: ["all_offerta"] });
queryClient.refetchQueries({ queryKey: ["detail_offerta"] });
toast.success(t("Muvaffaqiyatli o'chirildi"), {
position: "top-center",
richColors: true,
});
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
position: "top-center",
richColors: true,
});
},
});
const { data: allOfferta } = useQuery({
queryKey: ["all_offerta"],
queryFn: () => {
return getAllOfferta({ page: 1, page_size: 99 });
},
select(data) {
return data.data.data;
},
});
const { data: detailOfferta } = useQuery({
queryKey: ["detail_offerta", editing],
queryFn: () => {
return getOneOfferta(editing!);
},
select(data) {
return data.data.data;
},
enabled: !!editing,
});
useEffect(() => {
if (editing && detailOfferta) {
setForm({
active: detailOfferta.is_active,
audience:
detailOfferta.person_type === "individual"
? "Jismoniy shaxslar"
: "Yuridik shaxslar",
content: detailOfferta.content,
title: detailOfferta.title,
});
}
}, [detailOfferta, editing]);
function handleCreateOrUpdate() {
const validation = validate(form);
if (Object.keys(validation).length) {
setErrors(validation);
const newErrors: Record<string, string> = {};
if (!form.title.trim()) {
newErrors.title = "Sarlavha kiritish majburiy";
} else if (form.title.trim().length < 3) {
newErrors.title = "Sarlavha kamida 3 ta belgidan iborat bo'lishi kerak";
}
const plainText = form.content.replace(/<[^>]+>/g, "").trim();
if (!plainText) {
newErrors.content = "Kontent kiritish majburiy";
} else if (plainText.length < 10) {
newErrors.content = "Kontent kamida 10 ta belgidan iborat bo'lishi kerak";
}
if (!form.audience) {
newErrors.audience = "Kimlar uchun degan maydonni tanlang";
}
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) {
toast.error(t("Iltimos, barcha majburiy maydonlarni to'ldiring"), {
position: "top-center",
});
return;
}
if (editing) {
setItems((prev) =>
prev.map((it) =>
it.id === editing.id ? { ...it, ...(form as Offer) } : it,
),
);
resetForm();
} else {
const newItem: Offer = {
id: `of-${Date.now()}`,
title: (form.title || "Untitled").trim(),
audience: (form.audience as Offer["audience"]) || "Barcha",
content: (form.content || "").trim(),
active: form.active ?? true,
createdAt: new Date().toISOString(),
};
setItems((prev) => [newItem, ...prev]);
resetForm();
if (editing === null) {
create({
content: form.content,
is_active: form.active,
person_type:
form.audience === "Jismoniy shaxslar" ? "individual" : "legal_entity",
title: form.title,
});
} else if (editing) {
update({
body: {
content: form.content,
is_active: form.active,
person_type:
form.audience === "Jismoniy shaxslar"
? "individual"
: "legal_entity",
title: form.title,
},
id: editing,
});
}
}
function startEdit(item: Offer) {
function startEdit(item: number) {
setEditing(item);
setForm({ ...item });
setErrors({});
window.scrollTo({ top: 0, behavior: "smooth" });
}
function removeItem(id: string) {
setItems((prev) => prev.filter((p) => p.id !== id));
function removeItem(id: number) {
removeOfferta({ id });
}
function toggleActive(id: string) {
setItems((prev) =>
prev.map((p) => (p.id === id ? { ...p, active: !p.active } : p)),
);
function toggleActive(id: number, currentStatus: boolean) {
update({
id: id,
body: {
is_active: !currentStatus,
},
});
}
const filtered = items.filter((it) => {
const q = query.trim().toLowerCase();
if (!q) return true;
return (
it.title.toLowerCase().includes(q) ||
it.content.toLowerCase().includes(q) ||
it.audience.toLowerCase().includes(q)
);
});
return (
<div className="min-h-screen w-full p-6 bg-gray-900">
<div className="max-w-[90%] mx-auto space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold">Ommaviy oferta</h1>
<h1 className="text-3xl font-bold">{t("Ommaviy oferta")}</h1>
</div>
<Card className="bg-gray-900">
<CardHeader>
<CardTitle>
{editing ? "Tahrirish" : "Yangi oferta yaratish"}
{editing ? t("Tahrirlash") : t("Yangi oferta yaratish")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="text-sm font-medium">Sarlavha</label>
<label className="text-sm font-medium">{t("Sarlavha")}</label>
<Input
value={form.title || ""}
onChange={(e) =>
setForm((s) => ({ ...s, title: e.target.value }))
}
placeholder="Ommaviy oferta sarlavhasi"
placeholder={t("Ommaviy oferta sarlavhasi")}
className="mt-1"
/>
{errors.title && (
<p className="text-destructive text-sm mt-1">{errors.title}</p>
<p className="text-destructive text-sm mt-1">
{t(errors.title)}
</p>
)}
</div>
<div className="h-full w-[100%]">
<label className="text-sm font-medium">Kontent</label>
<div className="h-[280px] w-[100%]">
<label className="text-sm font-medium">{t("Kontent")}</label>
<div className="mt-1">
<ReactQuill
value={form.content || ""}
@@ -194,54 +274,41 @@ export default function OmmaviyOfertaCRUD() {
setForm((s) => ({ ...s, content: value }))
}
className="bg-gray-900 h-48"
placeholder="Oferta matnini kiriting..."
placeholder={t("Oferta matnini kiriting...")}
/>
</div>
{errors.content && (
<p className="text-destructive text-sm mt-1">
{errors.content}
<p className="text-destructive text-sm mt-12">
{t(errors.content)}
</p>
)}
</div>
<div className="grid grid-cols-1 gap-4 mt-24">
<div>
<label className="text-sm font-medium">Kimlar uchun</label>
<Select
value={form.audience || "Barcha"}
onValueChange={(value) =>
setForm((s) => ({
...s,
audience: value as Offer["audience"],
}))
}
>
<SelectTrigger className="mt-1 w-full !h-12">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Barcha">Barcha</SelectItem>
<SelectItem value="Jismoniy shaxslar">
Jismoniy shaxslar uchun
</SelectItem>
<SelectItem value="Yuridik shaxslar">
Yuridik shaxslar uchun
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={!!form.active}
onCheckedChange={(checked) =>
setForm((s) => ({ ...s, active: checked ? true : false }))
}
/>
<span>Faol</span>
</label>
</div>
<div>
<label className="text-sm font-medium">{t("Kimlar uchun")}</label>
<Select
value={form.audience || t("Barcha")}
onValueChange={(value) =>
setForm((s) => ({ ...s, audience: value }))
}
>
<SelectTrigger className="mt-1 w-full !h-12">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Jismoniy shaxslar">
{t("Jismoniy shaxslar uchun")}
</SelectItem>
<SelectItem value="Yuridik shaxslar">
{t("Yuridik shaxslar uchun")}
</SelectItem>
</SelectContent>
</Select>
{errors.audience && (
<p className="text-destructive text-sm mt-1">
{t(errors.audience)}
</p>
)}
</div>
<div className="flex gap-2 pt-4">
@@ -249,92 +316,91 @@ export default function OmmaviyOfertaCRUD() {
onClick={handleCreateOrUpdate}
className="bg-blue-600 hover:bg-blue-700"
>
{editing ? "Saqlash" : "Yaratish"}
{editing ? t("Saqlash") : t("Qo'shish")}
</Button>
<Button variant="outline" onClick={resetForm}>
Bekor qilish
{t("Bekor qilish")}
</Button>
</div>
</CardContent>
</Card>
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Qidirish sarlavha, kontent yoki auditoriya bo'yicha..."
className="max-w-sm"
/>
<div className="flex gap-4 text-sm text-muted-foreground">
<span>Natija: {filtered.length}</span>
<span>Barcha: {items.length}</span>
</div>
</div>
<div className="space-y-3">
{filtered.length === 0 && (
{allOfferta && allOfferta?.results.length === 0 && (
<Card>
<CardContent className="pt-6">
<p className="text-muted-foreground text-center">
Natija topilmadi.
{t("Natija topilmadi.")}
</p>
</CardContent>
</Card>
)}
{filtered.map((it) => (
{allOfferta?.results.map((it) => (
<Card key={it.id} className="overflow-hidden">
<CardContent className="pt-6">
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
<div className="flex-1">
<h3 className="font-semibold text-lg">{it.title}</h3>
<p className="text-sm text-muted-foreground mt-1">
{it.audience} {new Date(it.createdAt).toLocaleString()}
{it.person_type == "individual"
? t("Jismoniy shaxslar uchun")
: t("Yuridik shaxslar uchun")}
</p>
<p className="mt-3 text-sm line-clamp-3">{it.content}</p>
</div>
<div className="flex gap-2 items-center flex-wrap md:flex-col md:flex-nowrap">
<Button
onClick={() => startEdit(it)}
onClick={() => startEdit(it.id)}
variant="outline"
size="sm"
>
<Edit2 className="w-4 h-4 mr-1" />
Tahrirlash
{t("Tahrirlash")}
</Button>
<Button
onClick={() => toggleActive(it.id)}
variant={it.active ? "default" : "outline"}
onClick={() => toggleActive(it.id, it.is_active)}
variant={it.is_active ? "default" : "outline"}
size="sm"
className={
it.active ? "bg-green-600 hover:bg-green-700" : ""
it.is_active
? "w-full bg-green-600 hover:bg-green-700"
: "w-full"
}
>
{it.active ? "Faol" : "Faol emas"}
{it.is_active ? t("Faol") : t("Faol emas")}
</Button>
<Dialog>
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogTrigger asChild>
<Button variant="destructive" size="sm">
<Button
variant="destructive"
size="sm"
className="w-full"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="w-4 h-4 mr-1" />
O'chirish
{t("O'chirish")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>O'chirish tasdiqlash</DialogTitle>
<DialogTitle>{t("O'chirish tasdiqlash")}</DialogTitle>
<DialogDescription>
Haqiqatan ham bu ofertani o'chirmoqchimisiz? Bu amalni
bekor qilib bo'lmaydi.
{t(
"Haqiqatan ham bu ofertani o'chirmoqchimisiz? Bu amalni bekor qilib bo'lmaydi.",
)}
</DialogDescription>
<div className="flex gap-3 justify-end pt-4">
<Button>Bekor qilish</Button>
<Button onClick={() => setDeleteOpen(false)}>
{t("Bekor qilish")}
</Button>
<Button
variant={"destructive"}
onClick={() => removeItem(it.id)}
>
O'chirish
{t("O'chirish")}
</Button>
</div>
</DialogContent>

View File

@@ -0,0 +1,64 @@
import type {
GetSupportAgency,
GetSupportUser,
} from "@/pages/support/lib/types";
import httpClient from "@/shared/config/api/httpClient";
import { SUPPORT_AGENCY, SUPPORT_USER } from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios";
const getSupportUser = async (params: {
page: number;
page_size: number;
status: "pending" | "done" | "failed" | "";
}): Promise<AxiosResponse<GetSupportUser>> => {
const res = await httpClient.get(SUPPORT_USER, { params });
return res;
};
const updateSupportUser = async ({
body,
id,
}: {
id: number;
body: {
name: string;
phone_number: string;
travel_agency: number | null;
status: "pending" | "done" | "failed";
};
}) => {
const res = await httpClient.patch(`${SUPPORT_USER}${id}/`, body);
return res;
};
const deleteSupportUser = async ({ id }: { id: number }) => {
const res = await httpClient.delete(`${SUPPORT_USER}${id}/`);
return res;
};
//support for agency
const getSupportAgency = async (params: {
page: number;
page_size: number;
search: string;
status: "pending" | "approved" | "cancelled" | "";
}): Promise<AxiosResponse<GetSupportAgency>> => {
const res = await httpClient.get(SUPPORT_AGENCY, { params });
return res;
};
const getSupportAgencyDetail = async (
id: number,
): Promise<AxiosResponse<any>> => {
const res = await httpClient.get(`${SUPPORT_AGENCY}${id}/`);
return res;
};
export {
deleteSupportUser,
getSupportAgency,
getSupportAgencyDetail,
getSupportUser,
updateSupportUser,
};

View File

@@ -0,0 +1,80 @@
export interface GetSupportUser {
status: boolean;
data: {
links: {
previous: null | string;
next: null | string;
};
total_items: number;
total_pages: number;
page_size: number;
current_page: number;
results: GetSupportUserRes[];
};
}
export interface GetSupportUserRes {
id: number;
name: string;
phone_number: string;
travel_agency: null | number;
status: "pending" | "done" | "failed";
}
export interface GetSupportAgency {
status: boolean;
data: {
links: {
previous: null | string;
next: null | string;
};
total_items: number;
total_pages: number;
page_size: number;
current_page: number;
results: GetSupportAgencyRes[];
};
}
export interface GetSupportAgencyRes {
id: number;
status: "pending" | "approved" | "cancelled";
name: string;
addres: string;
email: string;
phone: string;
web_site: string;
travel_agency_documents: [
{
file: string;
},
];
}
export interface GetSupportAgencyDetail {
status: boolean;
data: {
links: {
previous: null | string;
next: null | string;
};
total_items: number;
total_pages: number;
page_size: number;
current_page: number;
results: {
id: number;
status: "pending" | "approved" | "cancelled";
name: string;
addres: string;
email: string;
phone: string;
web_site: string;
travel_agency_documents: [
{
file: string;
},
];
}[];
};
}

View File

@@ -1,5 +1,11 @@
import { XIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { getSupportAgency } from "@/pages/support/lib/api";
import type { GetSupportAgencyRes } from "@/pages/support/lib/types";
import formatPhone from "@/shared/lib/formatPhone";
import { Button } from "@/shared/ui/button";
import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, Loader2, XIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
interface Data {
@@ -43,110 +49,118 @@ const sampleData: Data[] = [
const SupportAgency = ({ requests = sampleData }) => {
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<Data | null>(null);
const { t } = useTranslation();
const [selected, setSelected] = useState<GetSupportAgencyRes | null>(null);
const filtered = useMemo(() => {
if (!query.trim()) return requests;
const q = query.toLowerCase();
return requests.filter(
(r) =>
(r.name && r.name.toLowerCase().includes(q)) ||
(r.email && r.email.toLowerCase().includes(q)) ||
(r.phone && r.phone.toLowerCase().includes(q)),
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["support_agency"],
queryFn: () =>
getSupportAgency({ page: 1, page_size: 10, search: "", status: "" }),
});
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 text-white gap-4 w-full">
<Loader2 className="w-10 h-10 animate-spin text-cyan-400" />
<p className="text-slate-400">{t("Ma'lumotlar yuklanmoqda...")}</p>
</div>
);
}, [requests, query]);
}
if (isError) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 w-full text-center text-white gap-4">
<AlertTriangle className="w-10 h-10 text-red-500" />
<p className="text-lg">
{t("Ma'lumotlarni yuklashda xatolik yuz berdi.")}
</p>
<Button
onClick={() => refetch()}
className="bg-gradient-to-r from-blue-600 to-cyan-600 text-white rounded-lg px-5 py-2 hover:opacity-90"
>
{t("Qayta urinish")}
</Button>
</div>
);
}
return (
<div className="p-4 w-full mx-auto">
<h2 className="text-2xl font-semibold mb-4">Agentlik soʻrovlari</h2>
<h2 className="text-2xl font-semibold mb-4">
{t("Agentlik so'rovlari")}
</h2>
<div className="flex gap-3 mb-6">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Qidiruv (ism, email yoki telefon)..."
placeholder={t("Qidiruv (ism, email yoki telefon)...")}
className="flex-1 p-2 border rounded-md focus:outline-none focus:ring"
/>
<button
onClick={() => setQuery("")}
className="px-4 py-2 bg-gray-500 rounded-md hover:bg-gray-300 transition"
>
Tozalash
{t("Tozalash")}
</button>
</div>
{filtered.length === 0 ? (
<div className="text-center text-gray-500">Soʻrov topilmadi.</div>
{data && data.data.data.results.length === 0 ? (
<div className="text-center text-gray-500">{t("So'rov topilmadi")}</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((r) => (
<div
key={r.id}
className="border rounded-lg p-4 shadow-sm hover:shadow-md transition bg-gray-800 text-white"
>
<div className="flex items-start justify-between">
<div>
<h3 className="text-lg font-medium">{r.name}</h3>
<p className="text-md">{r.address}</p>
{data &&
data.data.data.results.map((r) => (
<div
key={r.id}
className="border rounded-lg p-4 shadow-sm hover:shadow-md transition bg-gray-800 text-white"
>
<div className="flex items-start justify-between">
<div>
<h3 className="text-lg font-medium">{r.name}</h3>
<p className="text-md">{r.addres}</p>
</div>
<div className="text-md">{formatPhone(r.phone)}</div>
</div>
<div className="text-md">{r.phone}</div>
</div>
<div className="mt-3 text-sm text-white">
<div>
<strong>Email:</strong>{" "}
<Link to={`mailto:${r.email}`} className="text-white">
{r.email}
<div className="mt-3 text-sm text-white">
<div>
<strong>{t("Email")}:</strong>{" "}
<Link to={`mailto:${r.email}`} className="text-white">
{r.email}
</Link>
</div>
{r.web_site && (
<div>
<strong>{t("Veb-sayt")}:</strong>{" "}
<a
href={r.web_site}
target="_blank"
rel="noopener noreferrer"
className="text-white hover:underline"
>
{r.web_site.replace(/^https?:\/\//, "")}
</a>
</div>
)}
</div>
<div className="mt-4 flex gap-2">
<button
onClick={() => setSelected(r)}
className="px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition"
>
{t("Tafsilotlar")}
</button>
<Link
to={`mailto:${r.email}`}
className="px-3 py-1 rounded border text-sm transition"
>
{t("Javob yozish")}
</Link>
</div>
{r.instagram && (
<div>
<strong>Instagram:</strong>{" "}
<a
href={r.instagram}
target="_blank"
rel="noopener noreferrer"
className="text-white hover:underline"
>
@
{r.instagram.replace(
/^https?:\/\/(www\.)?instagram\.com\/?/,
"",
)}
</a>
</div>
)}
{r.web_site && (
<div>
<strong>Website:</strong>{" "}
<a
href={r.web_site}
target="_blank"
rel="noopener noreferrer"
className="text-white hover:underline"
>
{r.web_site.replace(/^https?:\/\//, "")}
</a>
</div>
)}
</div>
<div className="mt-4 flex gap-2">
<button
onClick={() => setSelected(r)}
className="px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition"
>
Tafsilotlar
</button>
<Link
to={`mailto:${r.email}`}
className="px-3 py-1 rounded border text-sm transition"
>
Javob yozish
</Link>
</div>
</div>
))}
))}
</div>
)}
@@ -167,11 +181,11 @@ const SupportAgency = ({ requests = sampleData }) => {
</button>
<h3 className="text-xl font-semibold mb-2">{selected.name}</h3>
<p className="text-md text-white mb-4">{selected.address}</p>
<p className="text-md text-white mb-4">{selected.addres}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<div className="text-md text-white">Email</div>
<div className="text-md text-white">{t("Email")}</div>
<a
href={`mailto:${selected.email}`}
className="block text-white hover:underline"
@@ -181,28 +195,12 @@ const SupportAgency = ({ requests = sampleData }) => {
</div>
<div>
<div className="text-md text-white">Telefon</div>
<div>{selected.phone}</div>
<div className="text-md text-white">{t("Telefon raqam")}</div>
<div>{formatPhone(selected.phone)}</div>
</div>
<div>
<div className="text-md text-white">Instagram</div>
{selected.instagram ? (
<a
href={selected.instagram}
target="_blank"
rel="noopener noreferrer"
className="block text-white hover:underline"
>
{selected.instagram}
</a>
) : (
<div className="text-white"></div>
)}
</div>
<div>
<div className="text-xs text-white">Website</div>
<div className="text-xs text-white">{t("Veb-sayt")}</div>
{selected.web_site ? (
<a
href={selected.web_site}
@@ -219,26 +217,27 @@ const SupportAgency = ({ requests = sampleData }) => {
</div>
<div className="mt-5">
<div className="text-sm font-medium mb-2">Hujjatlar</div>
{selected.documents && selected.documents.length > 0 ? (
<div className="text-sm font-medium mb-2">{t("Hujjatlar")}</div>
{selected.travel_agency_documents &&
selected.travel_agency_documents.length > 0 ? (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{selected.documents.map((doc, i) => (
{selected.travel_agency_documents.map((doc, i) => (
<a
key={i}
href={doc}
href={doc.file}
target="_blank"
rel="noopener noreferrer"
className="group relative aspect-square border-2 border-gray-200 rounded-lg overflow-hidden hover:border-blue-500 transition"
>
<img
src={doc}
src={doc.file}
alt={`Hujjat ${i + 1}`}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition flex items-end">
<div className="w-full bg-gradient-to-t from-black/70 to-transparent p-2">
<span className="text-white text-xs font-medium">
Hujjat {i + 1}
{t("Hujjat")} {i + 1}
</span>
</div>
</div>
@@ -246,7 +245,7 @@ const SupportAgency = ({ requests = sampleData }) => {
))}
</div>
) : (
<div className="text-gray-500">Hujjat topilmadi</div>
<div className="text-gray-500">{t("Hujjat topilmadi")}</div>
)}
</div>
@@ -258,7 +257,7 @@ const SupportAgency = ({ requests = sampleData }) => {
}}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 transition"
>
Qabul qilish
{t("Qabul qilish")}
</button>
<button
onClick={() => {
@@ -267,7 +266,7 @@ const SupportAgency = ({ requests = sampleData }) => {
}}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition"
>
Rad etish
{t("Rad etish")}
</button>
</div>
</div>

View File

@@ -1,5 +1,13 @@
"use client";
import { getDetailAgency } from "@/pages/agencies/lib/api";
import {
deleteSupportUser,
getSupportUser,
updateSupportUser,
} from "@/pages/support/lib/api"; // deleteSupportUser import qiling
import type { GetSupportUserRes } from "@/pages/support/lib/types";
import formatPhone from "@/shared/lib/formatPhone";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
@@ -10,129 +18,201 @@ import {
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { MessageCircle, Phone, User } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Loader2, Phone, Trash2, User } from "lucide-react";
import { useState } from "react";
type SupportRequest = {
id: number;
name: string;
phone: string;
message: string;
status: "Pending" | "Resolved";
};
const initialRequests: SupportRequest[] = [
{
id: 1,
name: "Alisher Karimov",
phone: "+998 90 123 45 67",
message: "Sayohat uchun viza hujjatlarini tayyorlashda yordam kerak.",
status: "Pending",
},
{
id: 2,
name: "Dilnoza Tursunova",
phone: "+998 91 765 43 21",
message: "Tolov muvaffaqiyatli otmadi, yordam bera olasizmi?",
status: "Resolved",
},
{
id: 3,
name: "Jamshid Abdullayev",
phone: "+998 93 555 22 11",
message: "Sayohat sanasini ozgartirishni istayman.",
status: "Pending",
},
{
id: 4,
name: "Jamshid Abdullayev",
phone: "+998 93 555 22 11",
message: "Sayohat sanasini ozgartirishni istayman.",
status: "Pending",
},
];
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
const SupportTours = () => {
const [requests, setRequests] = useState<SupportRequest[]>(initialRequests);
const [selected, setSelected] = useState<SupportRequest | null>(null);
const { t } = useTranslation();
const [selected, setSelected] = useState<GetSupportUserRes | null>(null);
const [selectedToDelete, setSelectedToDelete] =
useState<GetSupportUserRes | null>(null);
const queryClient = useQueryClient();
const [filterStatus, setFilterStatus] = useState<
"" | "pending" | "done" | "failed"
>("");
const handleToggleStatus = (id: number) => {
setRequests((prev) =>
prev.map((req) =>
req.id === id
? {
...req,
status: req.status === "Pending" ? "Resolved" : "Pending",
}
: req,
),
);
setSelected((prev) =>
prev
? {
...prev,
status: prev.status === "Pending" ? "Resolved" : "Pending",
}
: prev,
);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["support_user", filterStatus],
queryFn: () =>
getSupportUser({ page: 1, page_size: 99, status: filterStatus }),
});
const { data: agency } = useQuery({
queryKey: ["detail_agency", selected?.travel_agency],
queryFn: () => getDetailAgency({ id: Number(selected?.travel_agency) }),
enabled: !!selected?.travel_agency,
});
const updateMutation = useMutation({
mutationFn: ({ body, id }: { id: number; body: GetSupportUserRes }) =>
updateSupportUser({ body, id }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["support_user"] });
setSelected(null);
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
richColors: true,
position: "top-center",
});
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => deleteSupportUser({ id }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["support_user"] });
setSelectedToDelete(null);
toast.success(t("Muvaffaqiyatli o'chirildi"), {
richColors: true,
position: "top-center",
});
},
onError: () => {
toast.error(t("O'chirishda xatolik yuz berdi"), {
richColors: true,
position: "top-center",
});
},
});
const handleToggleStatus = (body: GetSupportUserRes) => {
updateMutation.mutate({
body: {
...body,
status: body.status === "pending" ? "done" : "pending",
},
id: body.id,
});
};
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 text-white gap-4 w-full">
<Loader2 className="w-10 h-10 animate-spin text-cyan-400" />
<p className="text-slate-400">{t("Ma'lumotlar yuklanmoqda...")}</p>
</div>
);
}
if (isError) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 w-full text-center text-white gap-4">
<AlertTriangle className="w-10 h-10 text-red-500" />
<p className="text-lg">
{t("Ma'lumotlarni yuklashda xatolik yuz berdi.")}
</p>
<Button
onClick={() => refetch()}
className="bg-gradient-to-r from-blue-600 to-cyan-600 text-white rounded-lg px-5 py-2 hover:opacity-90"
>
{t("Qayta urinish")}
</Button>
</div>
);
}
return (
<div className="min-h-screen bg-gray-900 text-gray-100 p-6 space-y-6 w-full">
<h1 className="text-3xl font-bold tracking-tight mb-4 text-white">
Yordam sorovlari
{t("Yordam so'rovlari")}
</h1>
<div className="grid gap-5 sm:grid-cols-3 lg:grid-cols-3">
{requests.map((req) => (
<Card
key={req.id}
className="bg-gray-800/70 border border-gray-700 shadow-md hover:shadow-lg hover:bg-gray-800 transition-all duration-200"
{/* Status Tabs */}
<div className="flex gap-3 mb-4">
{[
{ label: t("Barchasi"), value: "" },
{ label: t("Kutilmoqda"), value: "pending" },
{ label: t("done"), value: "done" },
].map((tab) => (
<button
key={tab.value}
className={`px-4 py-2 rounded-lg font-medium text-sm transition-colors ${
filterStatus === tab.value
? "bg-blue-600 text-white"
: "bg-gray-700 text-gray-300 hover:bg-gray-600"
}`}
onClick={() => setFilterStatus(tab.value as any)}
>
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2 text-lg font-semibold text-white">
<User className="w-5 h-5 text-blue-400" />
{req.name}
</span>
{tab.label}
</button>
))}
</div>
{/* Cards */}
<div className="grid gap-5 sm:grid-cols-3 lg:grid-cols-3">
{data && data.data.data.results.length === 0 ? (
<div className="col-span-full flex flex-col items-center justify-center min-h-[50vh] w-full text-center text-white gap-4">
<p className="text-lg">{t("Natija topilmadi")}</p>
</div>
) : (
data?.data.data.results.map((req) => (
<Card
key={req.id}
className="bg-gray-800/70 border border-gray-700 shadow-md hover:shadow-lg hover:bg-gray-800 transition-all duration-200 justify-between"
>
<CardHeader className="pb-2 flex justify-between items-center">
<div className="flex gap-2">
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-white">
<User className="w-5 h-5 text-blue-400" />
{req.name}
</CardTitle>
</div>
<Badge
variant="outline"
className={`px-2 py-1 rounded-md text-xs font-medium ${
req.status === "Pending"
req.status === "pending"
? "bg-red-500/10 text-red-400 border-red-400/40"
: "bg-green-500/10 text-green-400 border-green-400/40"
}`}
>
{req.status === "Pending" ? "Kutilmoqda" : "Yakunlangan"}
{req.status === "pending" ? t("Kutilmoqda") : t("done")}
</Badge>
</CardTitle>
</CardHeader>
</CardHeader>
<CardContent className="space-y-3 mt-1">
<div className="flex items-center gap-2 text-sm text-gray-400">
<Phone className="w-4 h-4 text-gray-400" />
{req.phone}
</div>
<div className="flex items-start gap-2 text-gray-300">
<MessageCircle className="w-4 h-4 text-gray-400 mt-1" />
<p className="text-sm leading-relaxed">{req.message}</p>
</div>
<Button
variant="outline"
size="sm"
className="mt-3 w-full border-gray-600 text-gray-200 hover:bg-gray-700 hover:text-white"
onClick={() => setSelected(req)}
>
Batafsil korish
</Button>
</CardContent>
</Card>
))}
<CardContent className="space-y-3 mt-1">
{req.travel_agency !== null ? (
<span className="text-md text-gray-400">
{t("Agentlikka tegishli")}
</span>
) : (
<span className="text-md text-gray-400">
{t("Sayt bo'yicha")}
</span>
)}
<div className="flex items-center gap-2 mt-2 text-sm text-gray-400">
<Phone className="w-4 h-4 text-gray-400" />
{formatPhone(req.phone_number)}
</div>
<div className="grid grid-cols-2 justify-end items-end gap-2">
<Button
variant="outline"
size="sm"
className="mt-3 w-full border-gray-600 text-gray-200 hover:bg-gray-700 hover:text-white"
onClick={() => setSelected(req)}
>
{t("Batafsil ko'rish")}
</Button>
<Button
size="sm"
variant="destructive"
className="flex items-center gap-1"
onClick={() => setSelectedToDelete(req)}
>
<Trash2 className="w-4 h-4" /> {t("O'chirish")}
</Button>
</div>
</CardContent>
</Card>
))
)}
</div>
{/* Modal (Dialog) */}
{/* Detail Modal */}
<Dialog open={!!selected} onOpenChange={() => setSelected(null)}>
<DialogContent className="bg-gray-800 border border-gray-700 text-gray-100 sm:max-w-lg">
<DialogHeader>
@@ -145,24 +225,26 @@ const SupportTours = () => {
<div className="space-y-3 mt-2">
<div className="flex items-center gap-2 text-gray-400">
<Phone className="w-4 h-4" />
{selected?.phone}
</div>
<div className="flex items-start gap-2 text-gray-300">
<MessageCircle className="w-4 h-4 mt-1" />
<p className="text-sm leading-relaxed">{selected?.message}</p>
{selected && formatPhone(selected?.phone_number)}
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-400">Status:</span>
<div className="flex justify-between items-center gap-2">
{selected && selected.travel_agency && agency?.data.data && (
<span className="text-sm text-gray-400">
{t("Agentlikka tegishli")}: {agency?.data.data.name}
</span>
)}
<Badge
variant="outline"
className={`px-2 py-1 rounded-md text-xs font-medium ${
selected?.status === "Pending"
selected?.status === "pending"
? "bg-red-500/10 text-red-400 border-red-400/40"
: "bg-green-500/10 text-green-400 border-green-400/40"
}`}
>
{selected?.status === "Pending" ? "Kutilmoqda" : "Yakunlangan"}
{selected?.status === "pending"
? t("Kutilmoqda")
: t("Yakunlangan")}
</Badge>
</div>
</div>
@@ -173,25 +255,60 @@ const SupportTours = () => {
className="border-gray-600 text-gray-200 hover:bg-gray-700 hover:text-white"
onClick={() => setSelected(null)}
>
Yopish
{t("Yopish")}
</Button>
{selected && (
<Button
onClick={() => handleToggleStatus(selected.id)}
onClick={() => handleToggleStatus(selected)}
className={`${
selected.status === "Pending"
selected.status === "pending"
? "bg-green-600 hover:bg-green-700"
: "bg-red-600 hover:bg-red-700"
} text-white`}
>
{selected.status === "Pending"
? "Yakunlandi deb belgilash"
: "Kutilmoqda deb belgilash"}
{selected.status === "pending"
? t("Yakunlandi deb belgilash")
: t("Kutilmoqda deb belgilash")}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={!!selectedToDelete}
onOpenChange={() => setSelectedToDelete(null)}
>
<DialogContent className="bg-gray-800 border border-gray-700 text-gray-100 sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-lg font-semibold text-red-500 flex items-center gap-2">
<Trash2 className="w-5 h-5" />
{t("Diqqat! O'chirish")}
</DialogTitle>
</DialogHeader>
<p className="text-gray-300 mt-2">
{t("Siz rostdan ham ushbu so'rovni o'chirmoqchimisiz?")}
</p>
<DialogFooter className="flex justify-end gap-2 mt-4">
<Button
variant="outline"
className="border-gray-600 text-gray-200 hover:bg-gray-700 hover:text-white"
onClick={() => setSelectedToDelete(null)}
>
{t("Bekor qilish")}
</Button>
<Button
variant="destructive"
className="bg-red-600 hover:bg-red-700 text-white"
onClick={() =>
selectedToDelete && deleteMutation.mutate(selectedToDelete.id)
}
>
{t("O'chirish")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};

View File

@@ -0,0 +1,70 @@
import type {
GetDetailSiteSetting,
GetSiteSetting,
} from "@/pages/tour-settings/lib/types";
import httpClient from "@/shared/config/api/httpClient";
import { SITE_SETTING } from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios";
const createSiteSetting = async (body: {
address: string;
latitude: string;
longitude: string;
telegram: string;
instagram: string;
facebook: string;
twitter: string;
email: string;
main_phone: string;
other_phone: string;
}) => {
const res = await httpClient.post(SITE_SETTING, body);
return res;
};
const updateSiteSetting = async ({
body,
id,
}: {
id: number;
body: {
address: string;
latitude: string;
longitude: string;
telegram: string;
instagram: string;
facebook: string;
twitter: string;
email: string;
main_phone: string;
other_phone: string;
};
}) => {
const res = await httpClient.patch(`${SITE_SETTING}${id}/`, body);
return res;
};
const getSiteSetting = async (): Promise<AxiosResponse<GetSiteSetting>> => {
const res = await httpClient.get(SITE_SETTING);
return res;
};
const getDetailSiteSetting = async (
id: number,
): Promise<AxiosResponse<GetDetailSiteSetting>> => {
const res = await httpClient.get(`${SITE_SETTING}${id}/`);
return res;
};
const deleteSiteSetting = async (id: number) => {
const res = await httpClient.delete(`${SITE_SETTING}${id}/`);
return res;
};
export {
createSiteSetting,
deleteSiteSetting,
getDetailSiteSetting,
getSiteSetting,
updateSiteSetting,
};

View File

@@ -0,0 +1,43 @@
export interface GetSiteSetting {
status: boolean;
data: {
links: {
previous: string;
next: string;
};
total_items: number;
total_pages: number;
page_size: number;
current_page: number;
results: {
id: number;
address: string;
latitude: string;
longitude: string;
telegram: string;
instagram: string;
facebook: string;
twitter: string;
email: string;
main_phone: string;
other_phone: string;
}[];
};
}
export interface GetDetailSiteSetting {
status: boolean;
data: {
id: number;
address: string;
latitude: string;
longitude: string;
telegram: string;
instagram: string;
facebook: string;
twitter: string;
email: string;
main_phone: string;
other_phone: string;
};
}

View File

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

View File

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

View File

@@ -1,7 +1,5 @@
import z from "zod";
const fileSchema = z.instanceof(File, { message: "Rasm faylini yuklang" });
export const TourformSchema = z.object({
title: z.string().min(2, {
message: "Sarlavha kamida 2 ta belgidan iborat bo'lishi kerak",
@@ -171,7 +169,7 @@ export const TourformSchema = z.object({
z.object({
ticket_itinerary_image: z.array(
z.object({
image: fileSchema,
image: z.union([z.instanceof(File), z.string()]),
}),
),
title: z.string().min(1, "Sarlavha majburiy"),
@@ -186,4 +184,24 @@ export const TourformSchema = z.object({
}),
)
.min(1, { message: "Kamida bitta xizmat kiriting." }),
extra_service: z
.array(
z.object({
name: z.string().min(1, { message: "Xizmat nomi majburiy" }),
name_ru: z.string().min(1, { message: "Xizmat nomi (RU) majburiy" }),
}),
)
.min(1, { message: "Kamida bitta bepul xizmat kiriting." }),
paid_extra_service: z
.array(
z.object({
name: z.string().min(1, { message: "Xizmat nomi majburiy" }),
name_ru: z.string().min(1, { message: "Xizmat nomi (RU) majburiy" }),
price: z
.number()
.min(0, { message: "Narx manfiy bolishi mumkin emas." }),
}),
)
.min(1, { message: "Kamida bitta pullik xizmat kiriting." }),
});

View File

@@ -12,6 +12,7 @@ export interface GetAllTours {
results: {
id: number;
destination: string;
featured_tickets: boolean;
duration_days: number;
hotel_name: string;
price: number;
@@ -24,25 +25,37 @@ export interface GetAllTours {
export interface GetOneTours {
status: boolean;
data: {
id: 0;
id: number;
hotel_name: string;
hotel_rating: string;
hotel_amenities: string;
title: string;
title_ru: string;
title_uz: string;
price: number;
min_person: number;
max_person: number;
departure: string;
departure_ru: string;
departure_uz: string;
destination: string;
destination_ru: string;
destination_uz: string;
departure_time: string;
travel_time: string;
location_name: string;
location_name_ru: string;
location_name_uz: string;
passenger_count: number;
languages: string;
hotel_info: string;
hotel_info_ru: string;
hotel_info_uz: string;
duration_days: number;
rating: number;
hotel_meals: string;
hotel_meals_ru: string;
hotel_meals_uz: string;
slug: string;
visa_required: true;
image_banner: string;
@@ -50,7 +63,7 @@ export interface GetOneTours {
transports: [
{
price: number;
full_info: {
transport: {
name: string;
icon_name: string;
};
@@ -65,6 +78,7 @@ export interface GetOneTours {
{
name: string;
name_ru: string;
name_uz: string;
icon_name: string;
},
];
@@ -73,26 +87,58 @@ export interface GetOneTours {
image: string;
title: string;
title_ru: string;
title_uz: string;
desc: string;
desc_ru: string;
desc_uz: string;
},
];
ticket_itinerary: [
{
title: string;
title_ru: string;
duration: number;
},
];
ticket_itinerary: {
title: string;
title_ru: string;
title_uz: string;
duration: number;
ticket_itinerary_image: [
{
image: string;
},
];
ticket_itinerary_destinations: [
{
name: string;
name_ru: string;
name_uz: string;
},
];
}[];
ticket_hotel_meals: [
{
image: string;
name: string;
name_ru: string;
name_uz: string;
desc: string;
desc_ru: string;
desc_uz: string;
},
];
tariff: [
{
tariff: number;
price: number;
},
];
extra_service: {
name: string;
name_ru: string;
name_uz: string;
}[];
paid_extra_service: {
name: string;
name_ru: string;
name_uz: string;
price: number;
}[];
};
}
@@ -154,6 +200,7 @@ export interface Hotel_BadgeId {
export interface Badge {
id: number;
name: string;
name_uz: string;
name_ru: string;
color: string;
}
@@ -206,6 +253,7 @@ export interface Hotel_TranportId {
export interface Transport {
id: number;
name: string;
name_uz: string;
name_ru: string;
icon_name: string;
}
@@ -233,6 +281,7 @@ export interface Hotel_TypeId {
export interface Type {
id: number;
name: string;
name_uz: string;
name_ru: string;
}
@@ -273,6 +322,7 @@ export interface HotelFeaturesDetail {
export interface HotelFeatures {
id: number;
hotel_feature_type_name: string;
hotel_feature_type_name_uz: string;
hotel_feature_type_name_ru: string;
}
@@ -293,6 +343,7 @@ export interface HotelFeaturesTypeDetail {
id: number;
feature_name: string;
feature_name_ru: string;
feature_name_uz: string;
feature_type: {
id: number;
hotel_feature_type_name: string;
@@ -300,3 +351,182 @@ export interface HotelFeaturesTypeDetail {
};
};
}
export interface GetDetailTours {
status: boolean;
data: {
id: number;
hotel_name: string;
hotel_rating: string;
travel_agency_id: string;
hotel_amenities: string;
title: string;
title_ru: string;
title_uz: string;
price: number;
min_person: number;
max_person: number;
departure: string;
departure_ru: string;
departure_uz: string;
destination: string;
destination_ru: string;
destination_uz: string;
departure_time: string;
travel_time: string;
location_name: string;
location_name_ru: string;
location_name_uz: string;
passenger_count: number;
languages: string;
hotel_info: string;
hotel_info_ru: string;
hotel_info_uz: string;
duration_days: number;
rating: number;
hotel_meals: string;
hotel_meals_ru: string;
hotel_meals_uz: string;
slug: string;
visa_required: true;
image_banner: string;
badge: number[];
transports: {
price: number;
transport: {
name: string;
icon_name: string;
};
}[];
ticket_images: {
image: string;
}[];
ticket_amenities: {
name: string;
name_ru: string;
name_uz: string;
icon_name: string;
}[];
ticket_included_services: {
image: string;
title: string;
title_ru: string;
title_uz: string;
desc: string;
desc_ru: string;
desc_uz: string;
}[];
ticket_itinerary: {
title: string;
title_ru: string;
title_uz: string;
duration: number;
ticket_itinerary_image: [
{
image: string;
},
];
ticket_itinerary_destinations: [
{
name: string;
name_ru: string;
name_uz: string;
},
];
}[];
ticket_hotel_meals: [
{
image: string;
name: string;
name_ru: string;
name_uz: string;
desc: string;
desc_ru: string;
desc_uz: string;
},
];
tariff: [
{
tariff: {
name: string;
};
price: number;
},
];
extra_service: [
{
name: string;
name_ru: string;
name_uz: string;
},
];
paid_extra_service: [
{
name: string;
name_ru: string;
name_uz: string;
price: number;
},
];
ticket_comments: {
user: {
id: number;
username: string;
};
text: string;
rating: number;
}[];
};
}
export interface GetHotelRes {
status: boolean;
data: {
links: {
previous: null | string;
next: null | string;
};
total_items: number;
total_pages: number;
page_size: number;
current_page: number;
results: [
{
id: number;
name: string;
rating: number;
meal_plan: string;
ticket: number;
hotel_type: [
{
id: number;
name: string;
name_ru: string;
name_uz: string;
},
];
hotel_amenities: [
{
name: string;
name_ru: string;
name_uz: string;
icon_name: string;
},
];
hotel_features: [
{
id: number;
feature_name: string;
feature_name_uz: string;
feature_name_ru: string;
feature_type: {
id: number;
hotel_feature_type_name_ru: string;
hotel_feature_type_name_uz: string;
};
},
];
},
];
};
}

View File

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

View File

@@ -43,7 +43,12 @@ const CreateEditTour = () => {
</div>
</div>
{step === 1 && (
<StepOne setStep={setStep} data={data} isEditMode={isEditMode} />
<StepOne
setStep={setStep}
data={data}
isEditMode={isEditMode}
id={id}
/>
)}
{step === 2 && <StepTwo data={data} isEditMode={isEditMode} />}
</div>

View File

@@ -174,7 +174,7 @@ const FeaturesTable = ({
useEffect(() => {
if (badgeDetail) {
form.setValue("name", badgeDetail.data.data.hotel_feature_type_name);
form.setValue("name", badgeDetail.data.data.hotel_feature_type_name_uz);
form.setValue(
"name_ru",
badgeDetail.data.data.hotel_feature_type_name_ru,

View File

@@ -167,7 +167,7 @@ const FeaturesTableType = ({
useEffect(() => {
if (badgeDetail) {
form.setValue("name", badgeDetail.data.data.feature_name);
form.setValue("name", badgeDetail.data.data.feature_name_uz);
form.setValue("name_ru", badgeDetail.data.data.feature_name_ru);
}
}, [editId, badgeDetail]);

View File

@@ -155,7 +155,7 @@ const MealTable = ({
useEffect(() => {
if (typeDetail) {
form.setValue("name", typeDetail.data.data.name);
form.setValue("name", typeDetail.data.data.name_uz);
form.setValue("name_ru", typeDetail.data.data.name_ru);
}
}, [editId, typeDetail]);

View File

@@ -5,6 +5,7 @@ import {
hotelBadge,
hotelTarif,
hotelTransport,
updateTours,
} from "@/pages/tours/lib/api";
import { TourformSchema } from "@/pages/tours/lib/form";
import { useTicketStore } from "@/pages/tours/lib/store";
@@ -46,10 +47,12 @@ import z from "zod";
const StepOne = ({
setStep,
data,
id,
isEditMode,
}: {
setStep: Dispatch<SetStateAction<number>>;
data: GetOneTours | undefined;
id: string | undefined;
isEditMode: boolean;
}) => {
const [displayPrice, setDisplayPrice] = useState("");
@@ -88,6 +91,8 @@ const StepOne = ({
passenger_count: 1,
min_person: 1,
max_person: 1,
extra_service: [],
paid_extra_service: [],
languages: "",
duration: 1,
badges: [],
@@ -103,119 +108,152 @@ const StepOne = ({
const { addAmenity, setId } = useTicketStore();
useEffect(() => {
if (isEditMode && data?.data) {
const tourData = data.data;
if (!isEditMode || !data?.data) return;
form.setValue("title", tourData.title);
form.setValue("title_ru", formatPrice(tourData.title));
form.setValue("price", tourData.price);
setDisplayPrice(formatPrice(tourData.price));
form.setValue("passenger_count", tourData.passenger_count || 1);
form.setValue("min_person", tourData.min_person || 1);
form.setValue("max_person", tourData.max_person || 1);
form.setValue("departure", tourData.departure || "");
form.setValue("departure_ru", tourData.departure || "");
form.setValue("destination", tourData.destination || "");
form.setValue("destination_ru", tourData.destination || "");
form.setValue("location_name", tourData.location_name || "");
form.setValue("location_name_ru", tourData.location_name || "");
form.setValue("hotel_info", tourData.hotel_info || "");
form.setValue("hotel_info_ru", tourData.hotel_info || "");
form.setValue("hotel_meals_info", tourData.hotel_meals || "");
form.setValue("hotel_meals_info_ru", tourData.hotel_meals || "");
form.setValue("languages", tourData.languages || "");
form.setValue("duration", tourData.duration_days || 1);
form.setValue("visa_required", tourData.visa_required ? "yes" : "no");
form.setValue("badges", tourData.badge || []);
const tour = data.data;
// DateTime fields
if (tourData.departure_time) {
const departureDate = new Date(tourData.departure_time);
form.setValue("departureDateTime", {
date: departureDate,
time: departureDate.toTimeString().slice(0, 8), // HH:MM:SS
});
}
// 🔹 Oddiy text maydonlar
form.setValue("title", tour.title_uz ?? "");
form.setValue("title_ru", tour.title_ru ?? "");
form.setValue("price", tour.price ?? 0);
setDisplayPrice(formatPrice(tour.price ?? 0));
if (tourData.travel_time) {
const travelDate = new Date(tourData.travel_time);
form.setValue("travelDateTime", {
date: travelDate,
time: travelDate.toTimeString().slice(0, 8),
});
}
form.setValue("passenger_count", tour.passenger_count ?? 1);
form.setValue("min_person", tour.min_person ?? 1);
form.setValue("max_person", tour.max_person ?? 1);
// Amenities
if (tourData.ticket_amenities && tourData.ticket_amenities.length > 0) {
const amenities = tourData.ticket_amenities.map((item) => ({
name: item.name,
name_ru: item.name_ru,
icon_name: item.icon_name,
}));
form.setValue("amenities", amenities);
}
form.setValue("departure", tour.departure_uz ?? "");
form.setValue("departure_ru", tour.departure_ru ?? "");
form.setValue("destination", tour.destination_uz ?? "");
form.setValue("destination_ru", tour.destination_ru ?? "");
form.setValue("location_name", tour.location_name_uz ?? "");
form.setValue("location_name_ru", tour.location_name_ru ?? "");
if (
tourData.ticket_included_services &&
tourData.ticket_included_services.length > 0
) {
const services = tourData.ticket_included_services.map((item) => ({
image: item.image,
title: item.title,
title_ru: item.title_ru,
description: item.desc_uz || item.desc,
desc_ru: item.desc || item.desc,
}));
form.setValue("hotel_services", services);
}
form.setValue("hotel_info", tour.hotel_info_uz ?? "");
form.setValue("hotel_info_ru", tour.hotel_info_ru ?? "");
form.setValue("hotel_meals_info", tour.hotel_meals_uz ?? "");
form.setValue("hotel_meals_info_ru", tour.hotel_meals_ru ?? "");
if (
tourData.ticket_hotel_meals &&
tourData.ticket_hotel_meals.length > 0
) {
const meals = tourData.ticket_hotel_meals.map((item) => ({
image: item.image,
title: item.name,
title_ru: item.name_ru,
description: item.desc,
desc_ru: item.desc_ru,
}));
form.setValue("hotel_meals", meals);
}
form.setValue("languages", tour.languages ?? "");
form.setValue("duration", tour.duration_days ?? 1);
form.setValue("visa_required", tour.visa_required ? "yes" : "no");
form.setValue("badges", tour.badge ?? []);
// Transport
if (tourData.transports && tourData.transports.length > 0) {
const transports = tourData.transports.map((item, index) => ({
transport: index + 1, // Agar transport ID bo'lsa, uni ishlatish kerak
price: item.price,
}));
// const tariff = tourData.tar => ({
// transport: index + 1,
// price: item.price,
// }));
form.setValue("transport", transports);
// form.setValue("tarif", );
}
// Ticket itinerary
if (tourData.ticket_itinerary && tourData.ticket_itinerary.length > 0) {
const itinerary = tourData.ticket_itinerary.map((item) => ({
ticket_itinerary_image: [], // Image fayllarni alohida handle qilish kerak
title: item.title,
title_ru: item.title_ru,
duration: item.duration,
ticket_itinerary_destinations: [], // Agar destinations bo'lsa qo'shish kerak
}));
form.setValue("ticket_itinerary", itinerary);
}
form.setValue("banner", tourData.image_banner);
if (tourData.ticket_images && tourData.ticket_images.length > 0) {
const images = tourData.ticket_images.map((img) => img.image); // faqat linklarni olamiz
form.setValue("images", images);
}
// 🔹 Jonash vaqti
if (tour.departure_time) {
const d = new Date(tour.departure_time);
form.setValue("departureDateTime", {
date: d,
time: d.toTimeString().slice(0, 8),
});
}
}, [isEditMode, data, form]);
// 🔹 Qaytish vaqti
if (tour.travel_time) {
const d = new Date(tour.travel_time);
form.setValue("travelDateTime", {
date: d,
time: d.toTimeString().slice(0, 8),
});
}
// 🔹 Qulayliklar (amenities)
form.setValue(
"amenities",
tour.ticket_amenities?.map((a) => ({
name: a.name ?? "",
name_ru: a.name_ru ?? "",
icon_name: a.icon_name ?? "",
})) ?? [],
);
// 🔹 Xizmatlar (hotel_services)
form.setValue(
"hotel_services",
tour.ticket_included_services?.map((s) => ({
image: s.image ?? null,
title: s.title_uz ?? "",
title_ru: s.title_ru ?? "",
description: s.desc_uz ?? "",
desc_ru: s.desc_ru ?? "",
})) ?? [],
);
// 🔹 Taomlar (hotel_meals)
form.setValue(
"hotel_meals",
tour.ticket_hotel_meals?.map((m) => ({
image: m.image ?? null,
title: m.name ?? "",
title_ru: m.name_ru ?? "",
description: m.desc ?? "",
desc_ru: m.desc_ru ?? "",
})) ?? [],
);
// 🔹 Transport
const transports =
tour.transports?.map((t, i) => ({
transport: i + 1,
price: t.price ?? 0,
})) ?? [];
form.setValue("transport", transports);
setTransportPrices(transports.map((t) => formatPrice(t.price ?? 0))); // 👈 YANGI QOSHILGAN
// 🔹 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)));
// 🔹 Yonalishlar (ticket_itinerary)
form.setValue(
"ticket_itinerary",
tour.ticket_itinerary?.map((item) => ({
ticket_itinerary_image:
item.ticket_itinerary_image?.map((img) => ({
image: img.image,
})) ?? [],
title: item.title ?? "",
title_ru: item.title_ru ?? "",
duration: item.duration ?? 1,
ticket_itinerary_destinations:
item.ticket_itinerary_destinations?.map((d) => ({
name: d.name ?? "",
name_ru: d.name_ru ?? "",
})) ?? [],
})) ?? [],
);
// 🔹 Banner va rasmlar
form.setValue("banner", tour.image_banner ?? null);
form.setValue("images", tour.ticket_images?.map((img) => img.image) ?? []);
// 🔹 Bepul xizmatlar (extra_service)
form.setValue(
"extra_service",
tour.extra_service?.map((s) => ({
name: s.name_uz ?? s.name ?? "",
name_ru: s.name_ru ?? "",
})) ?? [],
);
// 🔹 Pullik xizmatlar (paid_extra_service)
form.setValue(
"paid_extra_service",
tour.paid_extra_service?.map((s) => ({
name: s.name_uz ?? s.name ?? "",
name_ru: s.name_ru ?? "",
price: s.price ?? 0,
})) ?? [],
);
// 🔹 TicketStore uchun id
setId(tour.id);
}, [isEditMode, data, form, setId]);
const { watch, setValue } = form;
const selectedDate = watch("departureDateTime.date");
@@ -238,6 +276,22 @@ const StepOne = ({
},
});
const { mutate: update } = useMutation({
mutationFn: ({ body, id }: { id: number; body: FormData }) => {
return updateTours({ body, id });
},
onSuccess: (res) => {
setId(res.data.data.id);
setStep(2);
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
richColors: true,
position: "top-center",
});
},
});
function onSubmit(value: z.infer<typeof TourformSchema>) {
const formData = new FormData();
@@ -273,7 +327,9 @@ const StepOne = ({
formData.append("hotel_meals_ru", value.hotel_meals_info_ru);
formData.append("duration_days", String(value.duration));
formData.append("rating", String("0.0"));
formData.append("image_banner", value.banner);
if (value.banner instanceof File) {
formData.append("image_banner", value.banner);
}
value.tarif.forEach((e, i) => {
formData.append(`tariff[${i}]tariff`, String(e.tariff));
formData.append(`tariff[${i}]price`, String(e.price));
@@ -285,7 +341,11 @@ const StepOne = ({
value.badges?.forEach((e, i) => {
formData.append(`badge[${i}]`, String(e));
});
value.images.forEach((e) => formData.append("ticket_images", e));
value.images.forEach((e) => {
if (e instanceof File) {
formData.append("ticket_images", e);
}
});
value.amenities.forEach((e, i) => {
formData.append(`ticket_amenities[${i}]name`, e.name);
formData.append(`ticket_amenities[${i}]name_ru`, e.name_ru);
@@ -297,43 +357,67 @@ const StepOne = ({
});
});
value.hotel_services.forEach((e, i) => {
formData.append(`ticket_included_services[${i}]image`, e.image);
formData.append(`ticket_included_services[${i}]title`, e.title);
formData.append(`ticket_included_services[${i}]title_ru`, e.title_ru);
formData.append(`ticket_included_services[${i}]desc_ru`, e.desc_ru);
formData.append(`ticket_included_services[${i}]desc`, e.description);
if (e instanceof File) {
formData.append(`ticket_included_services[${i}]image`, e.image);
formData.append(`ticket_included_services[${i}]title`, e.title);
formData.append(`ticket_included_services[${i}]title_ru`, e.title_ru);
formData.append(`ticket_included_services[${i}]desc_ru`, e.desc_ru);
formData.append(`ticket_included_services[${i}]desc`, e.description);
}
});
value.ticket_itinerary.forEach((e, i) => {
formData.append(`ticket_itinerary[${i}]title`, e.title);
formData.append(`ticket_itinerary[${i}]title_ru`, e.title_ru);
formData.append(`ticket_itinerary[${i}]duration`, String(e.duration));
e.ticket_itinerary_image.forEach((e, f) => {
formData.append(
`ticket_itinerary[${i}]ticket_itinerary_image[${f}]image`,
e.image,
);
});
e.ticket_itinerary_destinations.forEach((e, f) => {
formData.append(
`ticket_itinerary[${i}]ticket_itinerary_destinations[${f}]name`,
String(e.name),
);
formData.append(
`ticket_itinerary[${i}]ticket_itinerary_destinations[${f}]name_ru`,
String(e.name_ru),
);
e.ticket_itinerary_image.forEach((l, f) => {
if (e instanceof File) {
formData.append(`ticket_itinerary[${i}]title`, e.title);
formData.append(`ticket_itinerary[${i}]title_ru`, e.title_ru);
formData.append(`ticket_itinerary[${i}]duration`, String(e.duration));
formData.append(
`ticket_itinerary[${i}]ticket_itinerary_image[${f}]image`,
l.image,
);
e.ticket_itinerary_destinations.forEach((e, f) => {
formData.append(
`ticket_itinerary[${i}]ticket_itinerary_destinations[${f}]name`,
String(e.name),
);
formData.append(
`ticket_itinerary[${i}]ticket_itinerary_destinations[${f}]name_ru`,
String(e.name_ru),
);
});
}
});
});
value.hotel_meals.forEach((e, i) => {
formData.append(`ticket_hotel_meals[${i}]image`, e.image);
formData.append(`ticket_hotel_meals[${i}]name`, e.title);
formData.append(`ticket_hotel_meals[${i}]name_ru`, e.title_ru);
formData.append(`ticket_hotel_meals[${i}]desc`, e.description);
formData.append(`ticket_hotel_meals[${i}]desc_ru`, e.desc_ru);
if (e instanceof File) {
formData.append(`ticket_hotel_meals[${i}]image`, e.image);
formData.append(`ticket_hotel_meals[${i}]name`, e.title);
formData.append(`ticket_hotel_meals[${i}]name_ru`, e.title_ru);
formData.append(`ticket_hotel_meals[${i}]desc`, e.description);
formData.append(`ticket_hotel_meals[${i}]desc_ru`, e.desc_ru);
}
});
create(formData);
value.extra_service.forEach((e, i) => {
formData.append(`extra_service[${i}]name`, e.name);
formData.append(`extra_service[${i}]name_ru`, e.name_ru);
});
value.paid_extra_service.forEach((e, i) => {
formData.append(`paid_extra_service[${i}]name`, e.name);
formData.append(`paid_extra_service[${i}]name_ru`, e.name_ru);
formData.append(`paid_extra_service[${i}]price`, String(e.price));
});
if (isEditMode && id) {
update({
body: formData,
id: Number(id),
});
} else {
create(formData);
}
}
console.log(form.formState.errors);
const { data: badge } = useQuery({
queryKey: ["all_badge"],
queryFn: () => hotelBadge({ page: 1, page_size: 10 }),
@@ -392,7 +476,9 @@ const StepOne = ({
name="price"
render={() => (
<FormItem>
<Label className="text-md">{t("Narx")} (1 kishi uchun)</Label>
<Label className="text-md">
{t("Narx")} {t("(1 kishi uchun)")}
</Label>
<FormControl>
<Input
type="text"
@@ -1157,11 +1243,11 @@ const StepOne = ({
name="visa_required"
render={({ field }) => (
<FormItem className="space-y-3">
<Label>{t("Visa talab qilinadimi?")}</Label>
<Label>{t("Visa talab qilinadimi")}?</Label>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
className="flex gap-6"
>
<div className="flex items-center space-x-2">
@@ -1170,7 +1256,7 @@ const StepOne = ({
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="no" id="visa_no" />
<label htmlFor="visa_no">{t("Yoq")}</label>
<label htmlFor="visa_no">{t("Yo'q")}</label>
</div>
</RadioGroup>
</FormControl>
@@ -1287,6 +1373,206 @@ const StepOne = ({
)}
/>
<FormField
control={form.control}
name="extra_service"
render={() => (
<FormItem>
<Label className="text-md">{t("Bepul xizmatlar")}</Label>
<div className="flex flex-col gap-4">
{/* Ko'rsatilayotgan xizmatlar */}
<div className="flex flex-wrap gap-2">
{form.watch("extra_service").map((item, idx) => (
<Badge
key={idx}
variant="secondary"
className="px-3 py-1 text-sm flex items-center gap-2"
>
<span>{item.name}</span>
<button
type="button"
onClick={() => {
const current = form.getValues("extra_service");
form.setValue(
"extra_service",
current.filter((_, i) => i !== idx),
);
}}
className="ml-1 text-muted-foreground hover:text-destructive"
>
<XIcon className="size-4" />
</button>
</Badge>
))}
</div>
{/* Yangi xizmat qo'shish */}
<div className="flex gap-3 items-end flex-wrap">
<Input
id="extra_service_name"
placeholder={t("Xizmat nomi (UZ)")}
className="h-12 !text-md flex-1 min-w-[200px]"
/>
<Input
id="extra_service_name_ru"
placeholder={t("Xizmat nomi (RU)")}
className="h-12 !text-md flex-1 min-w-[200px]"
/>
<Button
type="button"
onClick={() => {
const nameInput = document.getElementById(
"extra_service_name",
) as HTMLInputElement;
const nameRuInput = document.getElementById(
"extra_service_name_ru",
) as HTMLInputElement;
if (nameInput.value && nameRuInput.value) {
const current = form.getValues("extra_service");
form.setValue("extra_service", [
...current,
{
name: nameInput.value,
name_ru: nameRuInput.value,
},
]);
nameInput.value = "";
nameRuInput.value = "";
}
}}
className="h-12"
>
{t("Qo'shish")}
</Button>
</div>
<FormMessage />
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="paid_extra_service"
render={() => (
<FormItem>
<Label className="text-md">{t("Pullik xizmatlar")}</Label>
<div className="flex flex-col gap-4">
{/* Ro'yxat */}
<div className="flex flex-wrap gap-2">
{(form.watch("paid_extra_service") || []).map((item, idx) => (
<Badge
key={idx}
variant="secondary"
className="px-3 py-1 text-sm flex items-center gap-2"
>
<span>
{item.name} {" "}
<strong>{formatPrice(item.price)} som</strong>
</span>
<button
type="button"
onClick={() => {
const current = form.getValues("paid_extra_service");
form.setValue(
"paid_extra_service",
current.filter((_, i) => i !== idx),
);
}}
className="ml-1 text-muted-foreground hover:text-destructive"
>
<XIcon className="size-4" />
</button>
</Badge>
))}
</div>
{/* Qo'shish formasi */}
<div className="flex gap-3 items-end flex-wrap">
<Input
id="paid_service_name"
placeholder={t("Xizmat nomi (UZ)")}
className="h-12 !text-md flex-1 min-w-[200px]"
/>
<Input
id="paid_service_name_ru"
placeholder={t("Xizmat nomi (RU)")}
className="h-12 !text-md flex-1 min-w-[200px]"
/>
{/* Narx maydoni */}
<Input
id="paid_service_price"
type="text"
inputMode="numeric"
placeholder="1 500 000"
className="h-12 !text-md w-[150px]"
onInput={(e) => {
const input = e.target as HTMLInputElement;
const raw = input.value.replace(/\D/g, "");
input.value = raw
? Number(raw).toLocaleString("ru-RU")
: "";
}}
/>
{/* Qo'shish tugmasi */}
<Button
type="button"
onClick={() => {
const nameInput = document.getElementById(
"paid_service_name",
) as HTMLInputElement;
const nameRuInput = document.getElementById(
"paid_service_name_ru",
) as HTMLInputElement;
const priceInput = document.getElementById(
"paid_service_price",
) as HTMLInputElement;
const raw = priceInput.value.replace(/\D/g, "");
const num = Number(raw);
if (
nameInput.value.trim() &&
nameRuInput.value.trim() &&
!isNaN(num)
) {
const current = form.getValues("paid_extra_service");
form.setValue("paid_extra_service", [
...current,
{
name: nameInput.value.trim(),
name_ru: nameRuInput.value.trim(),
price: num, // 🟢 0 ham bolishi mumkin
},
]);
// inputlarni tozalaymiz
nameInput.value = "";
nameRuInput.value = "";
priceInput.value = "";
}
}}
className="h-12"
>
{t("Qo'shish")}
</Button>
</div>
<FormMessage />
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="hotel_info"
@@ -1313,7 +1599,7 @@ const StepOne = ({
<Label className="text-md">{t("Mehmonxona haqida")} (ru)</Label>
<FormControl>
<Textarea
placeholder={t("Mehmonxona haqida (ru)")}
placeholder={t("Mehmonxona haqida") + " (ru)"}
{...field}
className="min-h-48 max-h-60 !text-md"
/>
@@ -1389,7 +1675,7 @@ const StepOne = ({
<Input
id="hotel_service_title_ru"
placeholder={t("Xizmat nomi (ru)")}
placeholder={t("Xizmat nomi") + " (ru)"}
className="h-12 !text-md"
/>
@@ -1401,7 +1687,7 @@ const StepOne = ({
<Textarea
id="hotel_service_desc_ru"
placeholder={t("Xizmat tavsifi (ru)")}
placeholder={t("Xizmat tavsifi") + " (ru)"}
className="min-h-24 !text-md"
/>
@@ -1487,7 +1773,7 @@ const StepOne = ({
</Label>
<FormControl>
<Textarea
placeholder={t("Mehmonxona taomlari haqida (ru)")}
placeholder={t("Mehmonxona taomlari haqida") + " (ru)"}
{...field}
className="min-h-48 max-h-60"
/>
@@ -1563,7 +1849,7 @@ const StepOne = ({
<Input
id="hotel_meals_title_ru"
placeholder={t("Taom nomi (ru)")}
placeholder={t("Taom nomi") + " (ru)"}
className="h-12 !text-md"
/>
@@ -1575,7 +1861,7 @@ const StepOne = ({
<Textarea
id="hotel_meals_desc_ru"
placeholder={t("Taom tavsifi (ru)")}
placeholder={t("Taom tavsifi") + " (ru)"}
className="min-h-24 !text-md"
/>
@@ -1662,7 +1948,7 @@ const StepOne = ({
{item.ticket_itinerary_destinations[0]?.name}
</p>
<p className="text-sm text-muted-foreground">
{item.duration} kun
{item.duration} {t("kun")}
</p>
</div>
<button
@@ -1704,7 +1990,7 @@ const StepOne = ({
<Input
id="ticket_itinerary_title_ru"
placeholder={t("Sarlavha (RU)")}
placeholder={t("Sarlavha") + " (ru)"}
className="h-12 !text-md"
/>
@@ -1724,7 +2010,7 @@ const StepOne = ({
<Input
id="ticket_itinerary_destination_ru"
placeholder={t("Manzil (RU)")}
placeholder={t("Manzil") + " (ru)"}
className="h-12 !text-md"
/>

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,10 @@
"use client";
import { deleteTours, getAllTours } from "@/pages/tours/lib/api";
import {
addedPopularTours,
deleteTours,
getAllTours,
} from "@/pages/tours/lib/api";
import formatPrice from "@/shared/lib/formatPrice";
import { Button } from "@/shared/ui/button";
import {
@@ -10,6 +14,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Switch } from "@/shared/ui/switch";
import {
Table,
TableBody,
@@ -28,15 +33,18 @@ import {
Plane,
PlusCircle,
Trash2,
X,
} from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
const Tours = () => {
const { t } = useTranslation();
const [page, setPage] = useState(1);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [showPopularDialog, setShowPopularDialog] = useState(false);
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -45,18 +53,54 @@ const Tours = () => {
queryFn: () => getAllTours({ page: page, page_size: 10 }),
});
const { data: popularTour } = useQuery({
queryKey: ["popular_tours"],
queryFn: () =>
getAllTours({ page: 1, page_size: 10, featured_tickets: true }),
});
const { mutate } = useMutation({
mutationFn: (id: number) => deleteTours({ id }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["all_tours"] });
setDeleteId(null);
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
richColors: true,
position: "top-center",
});
},
});
const { mutate: popular } = useMutation({
mutationFn: ({ id, value }: { id: number; value: number }) =>
addedPopularTours({ id, value }),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["all_tours"] });
queryClient.refetchQueries({ queryKey: ["popular_tours"] });
},
onError: () => {
if (popularTour?.data.data.results.length === 5) {
setShowPopularDialog(true);
} else {
toast.error(t("Xatolik yuz berdi"), {
richColors: true,
position: "top-center",
});
}
},
});
const confirmDelete = (id: number) => {
mutate(id);
};
const removeFromPopular = (id: number) => {
popular({ id, value: 0 });
setShowPopularDialog(false);
};
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 text-white gap-4 w-full">
@@ -105,6 +149,9 @@ const Tours = () => {
</TableHead>
<TableHead className="min-w-[180px]">{t("Mehmonxona")}</TableHead>
<TableHead className="min-w-[200px]">{t("Narxi")}</TableHead>
<TableHead className="min-w-[120px] text-center">
{t("Popular")}
</TableHead>
<TableHead className="min-w-[150px] text-center">
{t("Операции")}
</TableHead>
@@ -139,6 +186,18 @@ const Tours = () => {
</span>
</TableCell>
<TableCell className="text-center">
<Switch
checked={tour.featured_tickets}
onCheckedChange={() =>
popular({
id: tour.id,
value: tour.featured_tickets ? 0 : 1,
})
}
/>
</TableCell>
<TableCell className="text-center">
<div className="flex gap-2 justify-center">
<Button
@@ -172,6 +231,7 @@ const Tours = () => {
</Table>
</div>
{/* Delete Tour Dialog */}
<Dialog open={deleteId !== null} onOpenChange={() => setDeleteId(null)}>
<DialogContent className="sm:max-w-[425px] bg-gray-900">
<DialogHeader>
@@ -201,6 +261,63 @@ const Tours = () => {
</DialogContent>
</Dialog>
{/* Popular Tours Dialog */}
<Dialog open={showPopularDialog} onOpenChange={setShowPopularDialog}>
<DialogContent className="sm:max-w-[600px] bg-gray-900">
<DialogHeader>
<DialogTitle className="text-xl">
{t("Popular turlar (5/5)")}
</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-muted-foreground mb-4">
{t(
"Popular turlar ro'yxati to'lgan. Yangi tur qo'shish uchun biror turni o'chiring.",
)}
</p>
<div className="space-y-2 max-h-[400px] overflow-y-auto">
{popularTour?.data.data.results.map((tour) => (
<div
key={tour.id}
className="flex items-center justify-between p-3 border border-slate-700 rounded-lg hover:bg-slate-800/50 transition-colors"
>
<div className="flex items-center gap-3 flex-1">
<Plane className="w-4 h-4 text-primary flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-semibold truncate">
{tour.destination}
</p>
<p className="text-xs text-muted-foreground">
{tour.duration_days} kun {tour.hotel_name}
</p>
</div>
<span className="text-green-600 font-bold text-sm flex-shrink-0">
{formatPrice(tour.price, true)}
</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => removeFromPopular(tour.id)}
className="ml-2 text-red-500 hover:text-red-600 hover:bg-red-500/10"
>
<X className="w-4 h-4" />
</Button>
</div>
))}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowPopularDialog(false)}
>
{t("Yopish")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="flex justify-end mt-10 gap-3">
<button
disabled={page === 1}

View File

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