From f16b9044a136d801211679e5a40cfbf42847211b Mon Sep 17 00:00:00 2001 From: soheil khaledabadi Date: Tue, 1 Sep 2026 18:50:58 +0330 Subject: [PATCH] feat: add can_renew --- .../profile/section/ProductConfigList.tsx | 37 ++++- .../section/components/ConfigFilterBar.tsx | 143 ++++++++++++++++++ .../profile/section/components/ConfigItem.tsx | 4 + .../section/components/ModalDnsConfig.tsx | 111 +++++++++----- src/types/props.d.ts | 7 + 5 files changed, 266 insertions(+), 36 deletions(-) create mode 100644 src/pages/profile/section/components/ConfigFilterBar.tsx diff --git a/src/pages/profile/section/ProductConfigList.tsx b/src/pages/profile/section/ProductConfigList.tsx index 894fb2b..8a2c92c 100644 --- a/src/pages/profile/section/ProductConfigList.tsx +++ b/src/pages/profile/section/ProductConfigList.tsx @@ -4,8 +4,9 @@ import Button from "../../../components/button"; import { ProfileDetailsProps } from "../../../types/props"; import ConfigItem from "./components/ConfigItem"; import ConfigItemLoading from "./components/ConfigItemLoading"; +import ConfigFilterBar from "./components/ConfigFilterBar"; import ProfileImage from "./components/ProfileImage"; -import { Fragment } from "react/jsx-runtime"; +import { Fragment, useMemo, useState } from "react"; import { IoAlertCircleSharp } from "react-icons/io5"; import { Link } from "react-router-dom"; @@ -19,6 +20,18 @@ const ProductConfigList = ({ const configs = data?.result?.configs; const showInitialLoading = (isLoading || isFetching) && !data; + const [statusFilter, setStatusFilter] = useState("all"); + const [typeFilter, setTypeFilter] = useState("all"); + + const filteredConfigs = useMemo(() => { + return configs?.filter((item) => { + const matchesStatus = + statusFilter === "all" || item.status === statusFilter; + const matchesType = typeFilter === "all" || item.type === typeFilter; + return matchesStatus && matchesType; + }); + }, [configs, statusFilter, typeFilter]); + return (
@@ -39,6 +52,17 @@ const ProductConfigList = ({ لیست کانفیگ ها

)} + + {!showInitialLoading && !error && configs && configs.length > 0 && ( + + )} +
برو به فروشگاه
+ ) : filteredConfigs?.length === 0 ? ( +
+ +

+ با این فیلتر سرویسی پیدا نشد +

+
) : ( - configs?.map((item, index) => { + filteredConfigs?.map((item, index) => { return ( ); diff --git a/src/pages/profile/section/components/ConfigFilterBar.tsx b/src/pages/profile/section/components/ConfigFilterBar.tsx new file mode 100644 index 0000000..ead9a3d --- /dev/null +++ b/src/pages/profile/section/components/ConfigFilterBar.tsx @@ -0,0 +1,143 @@ +import { + IoAppsOutline, + IoCheckmarkCircle, + IoCloseCircle, + IoTimeOutline, +} from "react-icons/io5"; +import { Config } from "../../../../types/props"; + +const STATUS_META: Record< + string, + { label: string; icon: React.ReactNode; activeClass: string } +> = { + all: { + label: "همه", + icon: , + activeClass: "!bg-secondTheme !text-gray-900", + }, + active: { + label: "فعال", + icon: , + activeClass: "!bg-green-600 !text-white", + }, + expired: { + label: "منقضی شده", + icon: , + activeClass: "!bg-rose-600 !text-white", + }, + pending: { + label: "در انتظار ثبت", + icon: , + activeClass: "!bg-yellow-400 !text-gray-900", + }, +}; + +const TYPE_LABELS: Record = { + eshtraki: "اشتراکی", + eshtraki_single: "اشتراک تکی", + wireguard: "وایرگارد", + dns: "DNS", + gaming: "گیمینگ", +}; + +const typeLabel = (type: string | null) => { + if (!type) return "سایر"; + return TYPE_LABELS[type.toLowerCase()] || type; +}; + +interface ConfigFilterBarProps { + configs: Config[]; + statusFilter: string; + setStatusFilter: (value: string) => void; + typeFilter: string; + setTypeFilter: (value: string) => void; +} + +const ConfigFilterBar = ({ + configs, + statusFilter, + setStatusFilter, + typeFilter, + setTypeFilter, +}: ConfigFilterBarProps) => { + const statusCounts = configs.reduce>((acc, item) => { + if (item.status) acc[item.status] = (acc[item.status] || 0) + 1; + return acc; + }, {}); + const availableStatuses = Object.keys(statusCounts); + + const typeCounts = configs.reduce>((acc, item) => { + if (item.type) acc[item.type] = (acc[item.type] || 0) + 1; + return acc; + }, {}); + const availableTypes = Object.keys(typeCounts); + + if (availableStatuses.length < 2 && availableTypes.length < 2) return null; + + return ( +
+ {availableStatuses.length > 1 && ( +
+ {["all", ...availableStatuses].map((status) => { + const meta = STATUS_META[status] || { + label: status, + icon: null, + activeClass: "!bg-secondTheme !text-gray-900", + }; + const isActive = statusFilter === status; + const count = + status === "all" ? configs.length : statusCounts[status]; + + return ( + + ); + })} +
+ )} + + {availableTypes.length > 1 && ( +
1 ? "pt-2 border-t border-gray-700" : "" + }`} + > + {["all", ...availableTypes].map((type) => { + const isActive = typeFilter === type; + const count = type === "all" ? configs.length : typeCounts[type]; + + return ( + + ); + })} +
+ )} +
+ ); +}; + +export default ConfigFilterBar; diff --git a/src/pages/profile/section/components/ConfigItem.tsx b/src/pages/profile/section/components/ConfigItem.tsx index 0b7c2a7..e0514b6 100644 --- a/src/pages/profile/section/components/ConfigItem.tsx +++ b/src/pages/profile/section/components/ConfigItem.tsx @@ -33,6 +33,8 @@ const ConfigItem = ({ secondaryIpv6, dnsIpPending, dnsRegistrationExpired, + dnsExpiresAt, + canRenew, }: ConfigItemProps) => { const [isOpen, setIsOpen] = useState(false); const [isOpenDnsModal, setIsOpenDnsModal] = useState(false); @@ -141,6 +143,8 @@ const ConfigItem = ({ secondaryIpv6={secondaryIpv6} dnsIpPending={dnsIpPending} dnsRegistrationExpired={dnsRegistrationExpired} + dnsExpiresAt={dnsExpiresAt} + canRenew={canRenew} /> )}
diff --git a/src/pages/profile/section/components/ModalDnsConfig.tsx b/src/pages/profile/section/components/ModalDnsConfig.tsx index 4789437..7abc76a 100644 --- a/src/pages/profile/section/components/ModalDnsConfig.tsx +++ b/src/pages/profile/section/components/ModalDnsConfig.tsx @@ -14,23 +14,16 @@ import { ModalDnsConfigProps } from "../../../../types/props"; import useAxios from "../../../../hooks/useAxios"; import { queryClient } from "../../../../main"; -const IP_ADDRESS_REGEX = - /^(((\d{1,3}\.){3}\d{1,3})|([0-9a-fA-F:]+:+[0-9a-fA-F:]+))$/; +const IPV4_ADDRESS_REGEX = /^(\d{1,3}\.){3}\d{1,3}$/; -const CLIENT_IP_ENDPOINTS = [ - { - url: "https://api64.ipify.org?format=json", - parse: async (res: Response) => (await res.json())?.ip, - }, - { - url: "https://api.ipify.org?format=json", - parse: async (res: Response) => (await res.json())?.ip, - }, - { - url: "https://icanhazip.com", - parse: async (res: Response) => (await res.text()).trim(), - }, -]; +const isValidIpv4Address = (address: string) => { + if (!IPV4_ADDRESS_REGEX.test(address)) return false; + + return address.split(".").every((part) => { + const value = Number(part); + return Number.isInteger(value) && value >= 0 && value <= 255; + }); +}; const getErrorMessage = (err: AxiosError) => { const responseData = err.response?.data; @@ -55,11 +48,38 @@ const ModalDnsConfig = ({ secondaryIpv6, dnsIpPending, dnsRegistrationExpired, + dnsExpiresAt, + canRenew, }: ModalDnsConfigProps) => { const axios = useAxios(); const [ip, setIp] = useState(""); const [isDetecting, setIsDetecting] = useState(false); + const renewMutation = useMutation({ + mutationKey: ["renewDnsOrder", orderId], + mutationFn: () => { + return axios.post(`/profile/dns/${orderId}/renew`); + }, + }); + + const handleRenew = () => { + if (!orderId) { + toast.error("شناسه سفارش نامعتبر است"); + return; + } + toast.promise(renewMutation.mutateAsync(), { + loading: "درحال انتقال به درگاه پرداخت...", + success: (res) => { + const paymentAction = res?.data?.result?.payment?.action; + if (paymentAction) { + window.location.href = paymentAction; + } + return res?.data?.alert || "به درگاه پرداخت منتقل می‌شوید"; + }, + error: (err: AxiosError) => getErrorMessage(err), + }); + }; + const copyToClipboard = async (label: string, textToCopy?: string | null) => { if (!textToCopy) return; try { @@ -88,8 +108,8 @@ const ModalDnsConfig = ({ toast.error("شناسه سفارش نامعتبر است"); return; } - if (!IP_ADDRESS_REGEX.test(normalizedIp)) { - toast.error("آی‌پی وارد شده معتبر نیست"); + if (!isValidIpv4Address(normalizedIp)) { + toast.error("فقط آی‌پی نسخه ۴ معتبر است"); return; } setIp(normalizedIp); @@ -107,25 +127,22 @@ const ModalDnsConfig = ({ const handleAutoDetectIp = async () => { setIsDetecting(true); try { - for (const endpoint of CLIENT_IP_ENDPOINTS) { - try { - const res = await fetch(endpoint.url, { cache: "no-store" }); - if (!res.ok) continue; + const res = await axios.get("/user-ipv4"); + const detectedIp = res.data?.result?.ipv4 || res.data?.result?.ip; - const detectedIp = await endpoint.parse(res); - if (detectedIp && IP_ADDRESS_REGEX.test(detectedIp)) { - setIp(detectedIp); - toast.success("آی‌پی کاربر دریافت شد"); - return; - } - } catch { - continue; - } + if (detectedIp && isValidIpv4Address(detectedIp)) { + setIp(detectedIp); + toast.success(res.data?.alert || "آی‌پی نسخه ۴ کاربر دریافت شد"); + return; } - toast.error("دریافت آی‌پی خودکار ممکن نشد"); + toast.error("دریافت آی‌پی نسخه ۴ خودکار ممکن نشد"); } catch (err) { - toast.error(`دریافت آی‌پی خودکار با خطا مواجه شد: ${err}`); + toast.error( + err instanceof Error + ? `دریافت آی‌پی خودکار با خطا مواجه شد: ${err.message}` + : "دریافت آی‌پی خودکار با خطا مواجه شد" + ); } finally { setIsDetecting(false); } @@ -143,7 +160,7 @@ const ModalDnsConfig = ({
{dnsRegistrationExpired && ( - مهلت ثبت آی‌پی برای این سرویس به پایان رسیده است + مهلت این سرویس به پایان رسیده است. برای ادامه استفاده، سرویس را تمدید کنید )} {dnsIpPending && !dnsRegistrationExpired && ( @@ -152,6 +169,32 @@ const ModalDnsConfig = ({ )} + {canRenew && ( +
+
+ {dnsExpiresAt ? ( + + مهلت سرویس تا{" "} + + {new Date(dnsExpiresAt).toLocaleDateString("fa-IR")} + + + ) : ( + + تمدید سرویس DNS + + )} +
+ +
+ )} + {ipRows.length > 0 && (
diff --git a/src/types/props.d.ts b/src/types/props.d.ts index 519fe3c..0323682 100644 --- a/src/types/props.d.ts +++ b/src/types/props.d.ts @@ -137,6 +137,8 @@ export interface ConfigItemProps { secondaryIpv6?: string | null; dnsIpPending?: boolean; dnsRegistrationExpired?: boolean; + dnsExpiresAt?: string | null; + canRenew?: boolean; } export interface ModalDnsConfigProps { @@ -150,6 +152,8 @@ export interface ModalDnsConfigProps { secondaryIpv6?: string | null; dnsIpPending?: boolean; dnsRegistrationExpired?: boolean; + dnsExpiresAt?: string | null; + canRenew?: boolean; } // نوع Item برای آیتم‌های داخل Dropdown @@ -229,6 +233,7 @@ type Config = { username?: string | null; category_id: number; is_expired: boolean; + status?: "active" | "expired" | "pending"; use_max_traffic: boolean; primary_ipv4?: string | null; secondary_ipv4?: string | null; @@ -236,6 +241,8 @@ type Config = { secondary_ipv6?: string | null; dns_ip_pending?: boolean; dns_registration_expired?: boolean; + dns_expires_at?: string | null; + can_renew?: boolean; }; // تعریف تایپ داده‌های برگشتی