This commit is contained in:
Samandar Turgunboyev
2025-10-30 18:28:17 +05:00
parent 352efd6391
commit 39f5b8ca3c
13 changed files with 650 additions and 115 deletions

View File

@@ -44,6 +44,7 @@ import {
useQueryClient,
} from "@tanstack/react-query";
import {
ChevronLeft,
ChevronRight,
Loader2,
Pencil,
@@ -66,6 +67,7 @@ const faqForm = z.object({
const Faq = () => {
const [activeTab, setActiveTab] = useState<string>("");
const [currentPage, setCurrentPage] = useState(1);
const { t } = useTranslation();
const [openModal, setOpenModal] = useState(false);
const [editFaq, setEditFaq] = useState<number | null>(null);
@@ -94,17 +96,24 @@ const Faq = () => {
initialPageParam: 1,
});
// Barcha kategoriyalarni birlashtirib olish
useEffect(() => {
setCurrentPage(1);
}, [activeTab]);
const category =
categoryData?.pages.flatMap((page) => page.data.data.results) ?? [];
const { data: faq } = useQuery({
queryKey: ["all_faq", activeTab],
queryKey: ["all_faq", activeTab, currentPage],
queryFn: () => {
return getAllFaq({ page: 1, page_size: 10, category: Number(activeTab) });
return getAllFaq({
page: currentPage,
page_size: 10,
category: Number(activeTab),
});
},
select(data) {
return data.data.data.results;
return data.data.data;
},
enabled: !!activeTab,
});
@@ -323,7 +332,7 @@ const Faq = () => {
{/* Tabs content */}
{category.map((cat) => (
<TabsContent key={cat.id} value={String(cat.id)}>
{faq && faq?.length > 0 ? (
{faq && faq?.results.length > 0 ? (
<div className="border rounded-md overflow-hidden shadow-sm">
<Table>
<TableHeader>
@@ -337,7 +346,7 @@ const Faq = () => {
</TableRow>
</TableHeader>
<TableBody>
{faq.map((faq, index) => (
{faq.results.map((faq, index) => (
<TableRow key={faq.id}>
<TableCell className="text-center font-medium">
{index + 1}
@@ -380,6 +389,40 @@ const Faq = () => {
))}
</Tabs>
<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(faq?.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 === faq?.total_pages}
onClick={() =>
setCurrentPage((p) => Math.min(p + 1, faq ? faq.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={openModal} onOpenChange={setOpenModal}>
<DialogContent className="sm:max-w-[500px] h-[80vh] overflow-y-scroll bg-gray-900">
<DialogHeader>

View File

@@ -9,6 +9,13 @@ import type { AxiosResponse } from "axios";
const getAllOrder = async (params: {
page: number;
page_size: number;
order_status:
| "pending_payment"
| "pending_confirmation"
| "cancelled"
| "confirmed"
| "completed"
| "";
}): Promise<AxiosResponse<UserOrderData>> => {
const res = await httpClient.get(USER_ORDERS, { params });
return res;

View File

@@ -22,7 +22,7 @@ import {
Users,
XCircle,
} from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
@@ -119,12 +119,26 @@ export default function FinancePage() {
const [currentPage, setCurrentPage] = useState(1);
const [tab, setTab] = useState<"bookings" | "agencies">("bookings");
const [filterStatus, setFilterStatus] = useState<
"all" | "paid" | "pending" | "cancelled" | "refunded"
>("all");
| ""
| "pending_payment"
| "pending_confirmation"
| "confirmed"
| "completed"
| "cancelled"
>("");
useEffect(() => {
setCurrentPage(1);
}, [filterStatus]);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["list_order_user", currentPage],
queryFn: () => getAllOrder({ page: currentPage, page_size: 10 }),
queryKey: ["list_order_user", currentPage, filterStatus],
queryFn: () =>
getAllOrder({
page: currentPage,
page_size: 10,
order_status: filterStatus,
}),
});
const stats = [
@@ -333,7 +347,14 @@ export default function FinancePage() {
<>
{/* Filter */}
<div className="flex gap-2 mb-6 flex-wrap">
{["all", "paid", "pending", "cancelled", "refunded"].map((s) => (
{[
"",
"pending_payment",
"pending_confirmation",
"confirmed",
"completed",
"cancelled",
].map((s) => (
<button
key={s}
className={`px-4 py-2 rounded-lg transition-all ${
@@ -344,23 +365,26 @@ export default function FinancePage() {
onClick={() =>
setFilterStatus(
s as
| "all"
| "paid"
| "pending"
| "cancelled"
| "refunded",
| ""
| "pending_payment"
| "pending_confirmation"
| "confirmed"
| "completed"
| "cancelled",
)
}
>
{s === "all"
{s === ""
? t("Barcha bandlovlar")
: s === "paid"
? t("To'langan")
: s === "pending"
? t("Kutilmoqda")
: s === "cancelled"
? t("Bekor qilindi")
: t("Qaytarilgan")}
: s === "pending_payment"
? t("Pending Payment")
: s === "pending_confirmation"
? t("Pending Confirmation")
: s === "confirmed"
? t("Confirmed")
: s === "completed"
? t("Completed")
: t("Cancelled")}
</button>
))}
</div>

View File

@@ -9,17 +9,15 @@ export interface AllSeoData {
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;
},
];
results: {
id: number;
title: string;
description: string;
keywords: string;
og_title: string;
og_description: string;
og_image: string;
}[];
};
}
@@ -28,10 +26,20 @@ export interface DetailSeoData {
data: {
id: number;
title: string;
title_uz: string;
title_ru: string;
description: string;
description_uz: string;
description_ru: string;
keywords: string;
keywords_uz: string;
keywords_ru: string;
og_title: string;
og_title_uz: string;
og_title_ru: string;
og_description: string;
og_description_uz: string;
og_description_ru: string;
og_image: string;
};
}

View File

@@ -24,20 +24,30 @@ import { toast } from "sonner";
type SeoData = {
title: string;
title_ru: string;
description: string;
description_ru: string;
keywords: string;
keywords_ru: string;
ogTitle: string;
ogTitle_ru: string;
ogDescription: string;
ogDescription_ru: string;
ogImage: File | null | string;
};
export default function Seo() {
const [formData, setFormData] = useState<SeoData>({
title: "",
title_ru: "",
description: "",
description_ru: "",
keywords: "",
keywords_ru: "",
ogTitle: "",
ogTitle_ru: "",
ogDescription: "",
ogDescription_ru: "",
ogImage: null,
});
const { t } = useTranslation();
@@ -52,6 +62,11 @@ export default function Seo() {
});
setFormData({
description: "",
description_ru: "",
keywords_ru: "",
ogDescription_ru: "",
ogTitle_ru: "",
title_ru: "",
keywords: "",
ogDescription: "",
ogImage: null,
@@ -80,6 +95,11 @@ export default function Seo() {
setEdit(null);
setFormData({
description: "",
description_ru: "",
keywords_ru: "",
ogDescription_ru: "",
ogTitle_ru: "",
title_ru: "",
keywords: "",
ogDescription: "",
ogImage: null,
@@ -104,6 +124,11 @@ export default function Seo() {
setEdit(null);
setFormData({
description: "",
description_ru: "",
keywords_ru: "",
ogDescription_ru: "",
ogTitle_ru: "",
title_ru: "",
keywords: "",
ogDescription: "",
ogImage: null,
@@ -170,12 +195,17 @@ export default function Seo() {
useEffect(() => {
if (detailSeo) {
setFormData({
description: detailSeo.description,
keywords: detailSeo.keywords,
ogDescription: detailSeo.og_description,
description: detailSeo.description_uz,
keywords: detailSeo.keywords_uz,
ogDescription: detailSeo.og_description_uz,
ogImage: detailSeo.og_image,
ogTitle: detailSeo.og_title,
title: detailSeo.title,
ogTitle: detailSeo.og_title_uz,
title: detailSeo.title_uz,
description_ru: detailSeo.description_ru,
keywords_ru: detailSeo.keywords_ru,
ogDescription_ru: detailSeo.og_description_ru,
ogTitle_ru: detailSeo.og_title_ru,
title_ru: detailSeo.title_ru,
});
setImagePreview(detailSeo.og_image || null);
}
@@ -184,10 +214,15 @@ export default function Seo() {
const handleSave = () => {
const form = new FormData();
form.append("title", formData.title);
form.append("title_ru", formData.title_ru);
form.append("description", formData.description);
form.append("description_ru", formData.description_ru);
form.append("keywords", formData.keywords);
form.append("keywords_ru", formData.keywords_ru);
form.append("og_title", formData.ogTitle);
form.append("og_title_ru", formData.ogTitle_ru);
form.append("og_description", formData.ogDescription);
form.append("og_description_ru", formData.ogDescription_ru);
// faqat File bolsa qoshamiz
if (formData.ogImage instanceof File) {
@@ -250,6 +285,29 @@ export default function Seo() {
</div>
</div>
<div>
<label className="block text-sm font-semibold text-white mb-2">
<FileText className="inline w-4 h-4 mr-1" /> {t("Page Title")}{" "}
(ru)
</label>
<input
type="text"
name="title_ru"
value={formData.title_ru}
onChange={handleChange}
placeholder={t("Sahifa sarlavhasi (3060 belgi)") + " ru"}
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">
{isValidTitle && (
<CheckCircle className="w-5 h-5 text-green-400" />
)}
{getTitleLength() > 0 && !isValidTitle && (
<AlertCircle className="w-5 h-5 text-yellow-400" />
)}
</div>
</div>
{/* Description */}
<div>
<label className="block text-sm font-semibold text-white mb-2">
@@ -275,6 +333,30 @@ export default function Seo() {
</div>
</div>
<div>
<label className="block text-sm font-semibold text-white mb-2">
{t("Meta Description")} (ru)
</label>
<textarea
name="description_ru"
value={formData.description_ru}
onChange={handleChange}
placeholder={t("Sahifa tavsifi (120160 belgi)") + " (ru)"}
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">
<span className="text-sm text-slate-400">
{getDescriptionLength()} / 160
</span>
{isValidDescription && (
<CheckCircle className="w-5 h-5 text-green-400" />
)}
{getDescriptionLength() > 0 && !isValidDescription && (
<AlertCircle className="w-5 h-5 text-yellow-400" />
)}
</div>
</div>
{/* Keywords */}
<div>
<label className="block text-sm font-semibold text-white mb-2">
@@ -293,6 +375,25 @@ export default function Seo() {
</p>
</div>
<div>
<label className="block text-sm font-semibold text-white mb-2">
{t("Keywords")} (ru)
</label>
<input
type="text"
name="keywords_ru"
value={formData.keywords_ru}
onChange={handleChange}
placeholder={
t("Kalit so'zlar (vergul bilan ajratilgan)") + " (ru)"
}
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">
{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">
@@ -314,6 +415,20 @@ export default function Seo() {
/>
</div>
<div>
<label className="block text-sm text-slate-300 mb-2">
{t("OG Title")} (ru)
</label>
<input
type="text"
name="ogTitle_ru"
value={formData.ogTitle_ru}
onChange={handleChange}
placeholder={t("Ijtimoiy tarmoqdagi sarlavha") + " (ru)"}
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">
{t("OG Description")}
@@ -327,6 +442,19 @@ export default function Seo() {
/>
</div>
<div>
<label className="block text-sm text-slate-300 mb-2">
{t("OG Description")} (ru)
</label>
<textarea
name="ogDescription_ru"
value={formData.ogDescription_ru}
onChange={handleChange}
placeholder={t("Ijtimoiy tarmoqdagi tavsif") + " (ru)"}
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" />{" "}
@@ -368,7 +496,7 @@ export default function Seo() {
<button
onClick={handleSave}
disabled={isPending}
disabled={allSeo && allSeo.length !== 0}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
>
{isPending || editPending ? (

View File

@@ -13,7 +13,9 @@ const createOfferta = async ({
}: {
body: {
title: string;
title_ru: string;
content: string;
content_ru: string;
person_type: "individual" | "legal_entity";
is_active: boolean;
};
@@ -28,8 +30,10 @@ const updateOfferta = async ({
}: {
id: number;
body: {
title?: string;
content?: string;
title: string;
title_ru: string;
content: string;
content_ru: string;
person_type?: "individual" | "legal_entity";
is_active?: boolean;
};
@@ -41,6 +45,7 @@ const updateOfferta = async ({
const getAllOfferta = async (params: {
page: number;
page_size: number;
person_type?: "individual" | "legal_entity";
}): Promise<AxiosResponse<GetAllOfferta>> => {
const res = await httpClient.get(OFFERTA, { params });
return res;
@@ -63,6 +68,8 @@ const createHelpPage = async ({
}: {
body: {
title: string;
title_ru: string;
content_ru: string;
content: string;
page_type: "privacy_policy" | "user_agreement";
is_active: boolean;
@@ -75,6 +82,7 @@ const createHelpPage = async ({
const getAllHelpPage = async (params: {
page: number;
page_size: number;
page_type?: "privacy_policy" | "user_agreement";
}): Promise<AxiosResponse<GetAllHelpPage>> => {
const res = await httpClient.get(HELP_PAGE, { params });
return res;
@@ -93,8 +101,10 @@ const updateHelpPage = async ({
}: {
id: number;
body: {
title?: string;
content?: string;
title: string;
title_ru: string;
content_ru: string;
content: string;
page_type?: "privacy_policy" | "user_agreement";
is_active?: boolean;
};

View File

@@ -25,6 +25,8 @@ export interface GetDetailOfferta {
id: number;
title: string;
content: string;
title_ru: string;
content_ru: string;
person_type: "individual" | "legal_entity";
is_active: boolean;
};
@@ -56,7 +58,9 @@ export interface GetDetailHelpPage {
data: {
id: number;
title: string;
title_ru: string;
content: string;
content_ru: string;
page_type: "privacy_policy" | "user_agreement";
is_active: true;
};

View File

@@ -45,6 +45,34 @@ export default function PolicyCrud() {
},
});
const { data: PrivacyPolicy } = useQuery({
queryKey: ["privacy_policy"],
queryFn: () => {
return getAllHelpPage({
page: 1,
page_size: 99,
page_type: "privacy_policy",
});
},
select(data) {
return data.data.data;
},
});
const { data: UserAgreement } = useQuery({
queryKey: ["user_agreement"],
queryFn: () => {
return getAllHelpPage({
page: 1,
page_size: 99,
page_type: "user_agreement",
});
},
select(data) {
return data.data.data;
},
});
const [editing, setEditing] = useState<number | null>(null);
const { data: detail } = useQuery({
@@ -60,11 +88,15 @@ export default function PolicyCrud() {
const [form, setForm] = useState<{
title: string;
title_ru: string;
content_ru: string;
content: string;
page_type: "privacy_policy" | "user_agreement";
is_active: boolean;
}>({
title: "",
content_ru: "",
title_ru: "",
content: "",
page_type: "privacy_policy",
is_active: true,
@@ -78,6 +110,8 @@ export default function PolicyCrud() {
}: {
body: {
title: string;
title_ru: string;
content_ru: string;
content: string;
page_type: "privacy_policy" | "user_agreement";
is_active: boolean;
@@ -87,6 +121,8 @@ export default function PolicyCrud() {
resetForm();
queryClient.refetchQueries({ queryKey: ["help_page"] });
queryClient.refetchQueries({ queryKey: ["help_page_detail"] });
queryClient.refetchQueries({ queryKey: ["privacy_policy"] });
queryClient.refetchQueries({ queryKey: ["user_agreement"] });
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
@@ -103,8 +139,10 @@ export default function PolicyCrud() {
}: {
id: number;
body: {
title?: string;
content?: string;
title: string;
title_ru: string;
content_ru: string;
content: string;
page_type?: "privacy_policy" | "user_agreement";
is_active?: boolean;
};
@@ -113,6 +151,8 @@ export default function PolicyCrud() {
resetForm();
queryClient.refetchQueries({ queryKey: ["help_page"] });
queryClient.refetchQueries({ queryKey: ["help_page_detail"] });
queryClient.refetchQueries({ queryKey: ["privacy_policy"] });
queryClient.refetchQueries({ queryKey: ["user_agreement"] });
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
@@ -128,6 +168,8 @@ export default function PolicyCrud() {
resetForm();
queryClient.refetchQueries({ queryKey: ["help_page"] });
queryClient.refetchQueries({ queryKey: ["help_page_detail"] });
queryClient.refetchQueries({ queryKey: ["privacy_policy"] });
queryClient.refetchQueries({ queryKey: ["user_agreement"] });
},
onError: () => {
toast.error(t("Xatolik yuz berdi"), {
@@ -140,6 +182,8 @@ export default function PolicyCrud() {
function resetForm() {
setForm({
title: "",
title_ru: "",
content_ru: "",
content: "",
is_active: true,
page_type: "privacy_policy",
@@ -155,6 +199,8 @@ export default function PolicyCrud() {
is_active: detail.is_active,
page_type: detail.page_type,
title: detail.title,
content_ru: detail.content_ru,
title_ru: detail.title_ru,
});
}
}, [detail, editing]);
@@ -189,6 +235,8 @@ export default function PolicyCrud() {
is_active: form.is_active,
page_type: form.page_type,
title: form.title,
content_ru: form.content_ru,
title_ru: form.title_ru,
},
id: editing,
});
@@ -199,6 +247,8 @@ export default function PolicyCrud() {
is_active: form.is_active,
page_type: form.page_type,
title: form.title,
content_ru: form.content_ru,
title_ru: form.title_ru,
},
});
}
@@ -212,14 +262,13 @@ export default function PolicyCrud() {
deleteHelp(id);
}
function toggleActive(id: number, currentStatus: boolean) {
update({
id: id,
body: {
is_active: !currentStatus,
},
});
}
const individualExists = !!PrivacyPolicy?.results?.length;
const legalExists = !!UserAgreement?.results?.length;
const isAddDisabled =
!editing &&
((form.page_type === "privacy_policy" && individualExists) ||
(form.page_type === "user_agreement" && legalExists));
return (
<div className="min-h-screen w-full p-6 bg-gray-900">
@@ -253,6 +302,26 @@ export default function PolicyCrud() {
</p>
)}
</div>
<div>
<label className="text-sm font-medium">
{t("Sarlavha")} (ru)
</label>
<Input
value={form.title_ru || ""}
onChange={(e) =>
setForm((s) => ({ ...s, title_ru: e.target.value }))
}
placeholder={t("Yordam sahifasi sarlavhasi") + " (ru)"}
className="mt-1"
/>
{errors.title && (
<p className="text-destructive text-sm mt-1">
{t(errors.title)}
</p>
)}
</div>
<div className="h-[280px] w-[100%]">
<label className="text-sm font-medium">{t("Kontent")}</label>
<div className="mt-1">
@@ -272,6 +341,25 @@ export default function PolicyCrud() {
)}
</div>
<div className="h-[280px] w-[100%]">
<label className="text-sm font-medium">{t("Kontent")} (ru)</label>
<div className="mt-1">
<ReactQuill
value={form.content_ru || ""}
onChange={(value) =>
setForm((s) => ({ ...s, content_ru: value }))
}
className="bg-gray-900 h-48"
placeholder={t("Yordam matnini kiriting...") + " (ru)"}
/>
</div>
{errors.content && (
<p className="text-destructive text-sm mt-12">
{t(errors.content)}
</p>
)}
</div>
<div className="grid grid-cols-1 gap-4 mt-5">
<div>
<label className="text-sm font-medium">
@@ -285,15 +373,19 @@ export default function PolicyCrud() {
page_type: value as "privacy_policy" | "user_agreement",
}))
}
disabled={editing !== null}
>
<SelectTrigger className="mt-1 w-full !h-12">
<SelectValue placeholder={t("Sahifa turini tanlang")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="user_agreement">
<SelectItem value="user_agreement" disabled={legalExists}>
{t("Qollanma")}
</SelectItem>
<SelectItem value="privacy_policy">
<SelectItem
value="privacy_policy"
disabled={individualExists}
>
{t("Maxfiylik siyosati")}
</SelectItem>
</SelectContent>
@@ -316,6 +408,7 @@ export default function PolicyCrud() {
<div className="flex gap-2 pt-4">
<Button
onClick={handleCreateOrUpdate}
disabled={isAddDisabled}
className="bg-blue-600 hover:bg-blue-700"
>
{editing ? t("Saqlash") : t("Yaratish")}
@@ -358,19 +451,6 @@ export default function PolicyCrud() {
{t("Tahrirlash")}
</Button>
<Button
onClick={() => toggleActive(it.id, it.is_active)}
variant={it.is_active ? "default" : "outline"}
size="sm"
className={
it.is_active
? "bg-green-600 hover:bg-green-700 w-full"
: "w-full"
}
>
{it.is_active ? t("Faol") : t("Faol emas")}
</Button>
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogTrigger asChild>
<Button

View File

@@ -33,15 +33,20 @@ import { toast } from "sonner";
export default function OmmaviyOfertaCRUD() {
const { t } = useTranslation();
const [deleteOpen, setDeleteOpen] = useState<boolean>(false);
const [selectedId, setSelectedId] = useState<number | null>(null);
const queryClient = useQueryClient();
const [editing, setEditing] = useState<number | null>(null);
const [form, setForm] = useState<{
title: string;
title_ru: string;
content: string;
audience: string;
active: boolean;
content_ru: string;
}>({
title: "",
content_ru: "",
title_ru: "",
audience: "Jismoniy shaxslar",
content: "",
active: true,
@@ -51,6 +56,8 @@ export default function OmmaviyOfertaCRUD() {
function resetForm() {
setForm({
title: "",
content_ru: "",
title_ru: "",
audience: "Jismoniy shaxslar",
content: "",
active: true,
@@ -62,7 +69,9 @@ export default function OmmaviyOfertaCRUD() {
const { mutate: create } = useMutation({
mutationFn: (body: {
title: string;
title_ru: string;
content: string;
content_ru: string;
person_type: "individual" | "legal_entity";
is_active: boolean;
}) => createOfferta({ body }),
@@ -70,6 +79,8 @@ export default function OmmaviyOfertaCRUD() {
resetForm();
queryClient.refetchQueries({ queryKey: ["all_offerta"] });
queryClient.refetchQueries({ queryKey: ["detail_offerta"] });
queryClient.refetchQueries({ queryKey: ["individual_offerta"] });
queryClient.refetchQueries({ queryKey: ["legal_entity"] });
toast.success(t("Muvaffaqiyatli yaratildi"), {
position: "top-center",
richColors: true,
@@ -89,8 +100,10 @@ export default function OmmaviyOfertaCRUD() {
id,
}: {
body: {
title?: string;
content?: string;
title: string;
title_ru: string;
content: string;
content_ru: string;
person_type?: "individual" | "legal_entity";
is_active?: boolean;
};
@@ -101,6 +114,8 @@ export default function OmmaviyOfertaCRUD() {
setEditing(null);
queryClient.refetchQueries({ queryKey: ["all_offerta"] });
queryClient.refetchQueries({ queryKey: ["detail_offerta"] });
queryClient.refetchQueries({ queryKey: ["individual_offerta"] });
queryClient.refetchQueries({ queryKey: ["legal_entity"] });
toast.success(t("Muvaffaqiyatli yangilandi"), {
position: "top-center",
richColors: true,
@@ -118,8 +133,11 @@ export default function OmmaviyOfertaCRUD() {
mutationFn: ({ id }: { id: number }) => deleteOfferta(id),
onSuccess: () => {
resetForm();
setDeleteOpen(false);
queryClient.refetchQueries({ queryKey: ["all_offerta"] });
queryClient.refetchQueries({ queryKey: ["detail_offerta"] });
queryClient.refetchQueries({ queryKey: ["individual_offerta"] });
queryClient.refetchQueries({ queryKey: ["legal_entity"] });
toast.success(t("Muvaffaqiyatli o'chirildi"), {
position: "top-center",
richColors: true,
@@ -136,7 +154,38 @@ export default function OmmaviyOfertaCRUD() {
const { data: allOfferta } = useQuery({
queryKey: ["all_offerta"],
queryFn: () => {
return getAllOfferta({ page: 1, page_size: 99 });
return getAllOfferta({
page: 1,
page_size: 99,
});
},
select(data) {
return data.data.data;
},
});
const { data: individualOfferta } = useQuery({
queryKey: ["individual_offerta"],
queryFn: () => {
return getAllOfferta({
page: 1,
page_size: 99,
person_type: "individual",
});
},
select(data) {
return data.data.data;
},
});
const { data: legalOfferta } = useQuery({
queryKey: ["legal_entity"],
queryFn: () => {
return getAllOfferta({
page: 1,
page_size: 99,
person_type: "legal_entity",
});
},
select(data) {
return data.data.data;
@@ -158,6 +207,8 @@ export default function OmmaviyOfertaCRUD() {
if (editing && detailOfferta) {
setForm({
active: detailOfferta.is_active,
content_ru: detailOfferta.content_ru,
title_ru: detailOfferta.title_ru,
audience:
detailOfferta.person_type === "individual"
? "Jismoniy shaxslar"
@@ -197,6 +248,8 @@ export default function OmmaviyOfertaCRUD() {
if (editing === null) {
create({
content: form.content,
content_ru: form.content_ru,
title_ru: form.title_ru,
is_active: form.active,
person_type:
form.audience === "Jismoniy shaxslar" ? "individual" : "legal_entity",
@@ -207,6 +260,8 @@ export default function OmmaviyOfertaCRUD() {
body: {
content: form.content,
is_active: form.active,
content_ru: form.content_ru,
title_ru: form.title_ru,
person_type:
form.audience === "Jismoniy shaxslar"
? "individual"
@@ -222,18 +277,13 @@ export default function OmmaviyOfertaCRUD() {
setEditing(item);
}
function removeItem(id: number) {
removeOfferta({ id });
}
const individualExists = !!individualOfferta?.results?.length;
const legalExists = !!legalOfferta?.results?.length;
function toggleActive(id: number, currentStatus: boolean) {
update({
id: id,
body: {
is_active: !currentStatus,
},
});
}
const isAddDisabled =
!editing &&
((form.audience === "Jismoniy shaxslar" && individualExists) ||
(form.audience === "Yuridik shaxslar" && legalExists));
return (
<div className="min-h-screen w-full p-6 bg-gray-900">
@@ -265,7 +315,27 @@ export default function OmmaviyOfertaCRUD() {
</p>
)}
</div>
<div className="h-[280px] w-[100%]">
<div>
<label className="text-sm font-medium">
{t("Sarlavha")} (ru)
</label>
<Input
value={form.title_ru || ""}
onChange={(e) =>
setForm((s) => ({ ...s, title_ru: e.target.value }))
}
placeholder={t("Ommaviy oferta sarlavhasi") + " (ru)"}
className="mt-1"
/>
{errors.title && (
<p className="text-destructive text-sm mt-1">
{t(errors.title_ru)}
</p>
)}
</div>
<div className="h-[280px] w-full">
<label className="text-sm font-medium">{t("Kontent")}</label>
<div className="mt-1">
<ReactQuill
@@ -284,22 +354,48 @@ export default function OmmaviyOfertaCRUD() {
)}
</div>
<div className="h-[280px] w-full">
<label className="text-sm font-medium">{t("Kontent")} (ru)</label>
<div className="mt-1">
<ReactQuill
value={form.content_ru || ""}
onChange={(value) =>
setForm((s) => ({ ...s, content_ru: value }))
}
className="bg-gray-900 h-48"
placeholder={t("Oferta matnini kiriting...") + " (ru)"}
/>
</div>
{errors.content && (
<p className="text-destructive text-sm mt-12">
{t(errors.content_ru)}
</p>
)}
</div>
{/* Kimlar uchun */}
<div>
<label className="text-sm font-medium">{t("Kimlar uchun")}</label>
<Select
value={form.audience || t("Barcha")}
value={form.audience || ""}
onValueChange={(value) =>
setForm((s) => ({ ...s, audience: value }))
}
disabled={editing !== null}
>
<SelectTrigger className="mt-1 w-full !h-12">
<SelectValue />
<SelectValue placeholder={t("Offerta turini tanlang")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="Jismoniy shaxslar">
<SelectItem
value="Jismoniy shaxslar"
disabled={individualExists}
>
{t("Jismoniy shaxslar uchun")}
</SelectItem>
<SelectItem value="Yuridik shaxslar">
<SelectItem value="Yuridik shaxslar" disabled={legalExists}>
{t("Yuridik shaxslar uchun")}
</SelectItem>
</SelectContent>
@@ -311,9 +407,11 @@ export default function OmmaviyOfertaCRUD() {
)}
</div>
{/* Tugmalar */}
<div className="flex gap-2 pt-4">
<Button
onClick={handleCreateOrUpdate}
disabled={isAddDisabled}
className="bg-blue-600 hover:bg-blue-700"
>
{editing ? t("Saqlash") : t("Qo'shish")}
@@ -335,7 +433,6 @@ export default function OmmaviyOfertaCRUD() {
</CardContent>
</Card>
)}
{allOfferta?.results.map((it) => (
<Card key={it.id} className="overflow-hidden">
<CardContent className="pt-6">
@@ -360,31 +457,30 @@ export default function OmmaviyOfertaCRUD() {
{t("Tahrirlash")}
</Button>
<Button
onClick={() => toggleActive(it.id, it.is_active)}
variant={it.is_active ? "default" : "outline"}
size="sm"
className={
it.is_active
? "w-full bg-green-600 hover:bg-green-700"
: "w-full"
}
<Dialog
open={deleteOpen && selectedId === it.id}
onOpenChange={(open) => {
if (!open) {
setDeleteOpen(false);
setSelectedId(null);
}
}}
>
{it.is_active ? t("Faol") : t("Faol emas")}
</Button>
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogTrigger asChild>
<Button
variant="destructive"
size="sm"
className="w-full"
onClick={() => setDeleteOpen(true)}
onClick={() => {
setSelectedId(it.id);
setDeleteOpen(true);
}}
>
<Trash2 className="w-4 h-4 mr-1" />
{t("O'chirish")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>{t("O'chirish tasdiqlash")}</DialogTitle>
<DialogDescription>
@@ -393,12 +489,21 @@ export default function OmmaviyOfertaCRUD() {
)}
</DialogDescription>
<div className="flex gap-3 justify-end pt-4">
<Button onClick={() => setDeleteOpen(false)}>
<Button
onClick={() => {
setDeleteOpen(false);
setSelectedId(null);
}}
>
{t("Bekor qilish")}
</Button>
<Button
variant={"destructive"}
onClick={() => removeItem(it.id)}
variant="destructive"
onClick={() => {
removeOfferta({ id: selectedId! });
setDeleteOpen(false);
setSelectedId(null);
}}
>
{t("O'chirish")}
</Button>

View File

@@ -19,13 +19,22 @@ import {
DialogTitle,
} from "@/shared/ui/dialog";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Loader2, Phone, Trash2, User } from "lucide-react";
import { useState } from "react";
import {
AlertTriangle,
ChevronLeft,
ChevronRight,
Loader2,
Phone,
Trash2,
User,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
const SupportTours = () => {
const { t } = useTranslation();
const [currentPage, setCurrentPage] = useState(1);
const [selected, setSelected] = useState<GetSupportUserRes | null>(null);
const [selectedToDelete, setSelectedToDelete] =
useState<GetSupportUserRes | null>(null);
@@ -34,10 +43,18 @@ const SupportTours = () => {
"" | "pending" | "done" | "failed"
>("");
useEffect(() => {
setCurrentPage(1);
}, [filterStatus]);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["support_user", filterStatus],
queryKey: ["support_user", filterStatus, currentPage],
queryFn: () =>
getSupportUser({ page: 1, page_size: 99, status: filterStatus }),
getSupportUser({
page: currentPage,
page_size: 10,
status: filterStatus,
}),
});
const { data: agency } = useQuery({
@@ -210,6 +227,45 @@ const SupportTours = () => {
</>
)}
<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(data?.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 === data?.data.data?.total_pages}
onClick={() =>
setCurrentPage((p) =>
Math.min(
p + 1,
data?.data.data ? data?.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>
{/* Detail Modal */}
<Dialog open={!!selected} onOpenChange={() => setSelected(null)}>
<DialogContent className="bg-gray-800 border border-gray-700 text-gray-100 sm:max-w-lg">

View File

@@ -12,6 +12,8 @@ const createSiteSetting = async (body: {
longitude: string;
telegram: string;
instagram: string;
telegram_chat: string;
linkedin: string;
facebook: string;
twitter: string;
email: string;
@@ -32,6 +34,8 @@ const updateSiteSetting = async ({
latitude: string;
longitude: string;
telegram: string;
telegram_chat: string;
linkedin: string;
instagram: string;
facebook: string;
twitter: string;

View File

@@ -15,6 +15,8 @@ export interface GetSiteSetting {
latitude: string;
longitude: string;
telegram: string;
telegram_chat: string;
linkedin: string;
instagram: string;
facebook: string;
twitter: string;
@@ -33,6 +35,8 @@ export interface GetDetailSiteSetting {
latitude: string;
longitude: string;
telegram: string;
telegram_chat: string;
linkedin: string;
instagram: string;
facebook: string;
twitter: string;

View File

@@ -33,6 +33,7 @@ type MapClickEvent = {
type ContactInfo = {
telegram: string;
instagram: string;
telegram_chat: string;
facebook: string;
twiter: string;
linkedin: string;
@@ -44,7 +45,7 @@ type ContactInfo = {
async function getAddressFromCoords(lat: number, lon: number) {
const response = await fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json`,
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&accept-language=uz`,
);
const data = await response.json();
return data.display_name || "";
@@ -57,6 +58,7 @@ export default function ContactSettings() {
const [editing, setEditing] = useState<number | null>(null);
const [form, setForm] = useState<ContactInfo>({
telegram: "",
telegram_chat: "",
instagram: "",
facebook: "",
twiter: "",
@@ -80,12 +82,15 @@ export default function ContactSettings() {
facebook: string;
twitter: string;
email: string;
telegram_chat: string;
linkedin: string;
main_phone: string;
other_phone: string;
}) => createSiteSetting(body),
onSuccess: () => {
setForm({
telegram: "",
telegram_chat: "",
instagram: "",
facebook: "",
twiter: "",
@@ -121,6 +126,8 @@ export default function ContactSettings() {
telegram: string;
instagram: string;
facebook: string;
telegram_chat: string;
linkedin: string;
twitter: string;
email: string;
main_phone: string;
@@ -130,6 +137,7 @@ export default function ContactSettings() {
onSuccess: () => {
setForm({
telegram: "",
telegram_chat: "",
instagram: "",
facebook: "",
twiter: "",
@@ -157,6 +165,7 @@ export default function ContactSettings() {
onSuccess: () => {
setForm({
telegram: "",
telegram_chat: "",
instagram: "",
facebook: "",
twiter: "",
@@ -207,10 +216,11 @@ export default function ContactSettings() {
email: detail.email,
facebook: detail.facebook,
instagram: detail.instagram,
linkedin: detail.twitter,
linkedin: detail.linkedin,
phonePrimary: detail.main_phone,
phoneSecondary: detail.other_phone,
telegram: detail.telegram,
telegram_chat: detail.telegram_chat,
twiter: detail.twitter,
});
setCoords({
@@ -237,14 +247,41 @@ export default function ContactSettings() {
};
const saveContact = () => {
const shortLat = Number(coords.latitude.toFixed(6)); // 6ta raqamgacha
const shortLat = Number(coords.latitude.toFixed(6));
const shortLon = Number(coords.longitude.toFixed(6));
// Majburiy maydonlar tekshiruvi
if (!form.address.trim()) {
toast.error(t("Manzilni kiriting!"), {
position: "top-center",
richColors: true,
});
return;
}
if (!form.phonePrimary.trim() || form.phonePrimary.length < 9) {
toast.error(t("Asosiy telefon raqamini togri kiriting!"), {
position: "top-center",
richColors: true,
});
return;
}
if (!coords.latitude || !coords.longitude) {
toast.error(t("Xaritadan joy tanlang! (latitude/longitude majburiy)"), {
position: "top-center",
richColors: true,
});
return;
}
if (editing) {
update({
body: {
address: form.address,
email: form.email,
facebook: form.facebook,
linkedin: form.linkedin,
telegram_chat: form.telegram_chat,
instagram: form.instagram,
latitude: String(shortLat),
longitude: String(shortLon),
@@ -259,6 +296,8 @@ export default function ContactSettings() {
create({
address: form.address,
email: form.email,
linkedin: form.linkedin,
telegram_chat: form.telegram_chat,
facebook: form.facebook,
instagram: form.instagram,
latitude: String(shortLat),
@@ -277,6 +316,7 @@ export default function ContactSettings() {
instagram: "",
facebook: "",
twiter: "",
telegram_chat: "",
linkedin: "",
address: "",
email: "",
@@ -370,12 +410,26 @@ export default function ContactSettings() {
</div>
<div className="text-sm">{e.telegram || "—"}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">
Telegram Chat
</div>
<div className="text-sm">
{e.telegram_chat || "—"}
</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">
Instagram
</div>
<div className="text-sm">{e.linkedin || "—"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">
Facebook
@@ -438,6 +492,7 @@ export default function ContactSettings() {
zoom: 13,
}}
width="100%"
la
height="400px"
onClick={handleMapClick}
>
@@ -460,6 +515,13 @@ export default function ContactSettings() {
onChange={(e) => handleChange("telegram", e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Telegram Chat</Label>
<Input
value={form.telegram_chat || ""}
onChange={(e) => handleChange("telegram_chat", e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Instagram</Label>
<Input