From 9fd8543b7e3c7e6dc03d8c512d5093ab1bce6816 Mon Sep 17 00:00:00 2001 From: soheil khaledabadi Date: Thu, 13 Aug 2026 16:38:49 +0330 Subject: [PATCH] feat: add new feature and fix bugs --- src/App.tsx | 5 + src/common/modal/index.tsx | 6 +- src/components/dropdown/index.tsx | 31 +-- src/components/progressSpace/index.tsx | 4 +- src/layout/LandingLayout.tsx | 5 +- src/layout/index.tsx | 2 +- .../components/AdvancedVerificationNotice.tsx | 91 +++++++ src/pages/advancedVerification/constants.ts | 37 +++ src/pages/advancedVerification/index.tsx | 35 +++ .../productList/components/ListAllProduct.tsx | 43 ++-- src/pages/profile/index.tsx | 33 +-- .../profile/section/ProductConfigList.tsx | 23 +- src/pages/profile/section/TelegramBanner.tsx | 24 ++ .../profile/section/components/ConfigItem.tsx | 117 +++++++-- .../section/components/ModalConfigDetails.tsx | 57 +++-- .../section/components/ModalDnsConfig.tsx | 239 ++++++++++++++++++ src/pages/profile/utils/wireguardConfig.ts | 71 ++++++ src/pages/rule/section/Rules.tsx | 8 + .../shop/section/productList/ProductList.tsx | 5 +- .../productList/components/ListAllProduct.tsx | 17 +- .../productList/components/ModalBuy.tsx | 213 ++++++++++++---- .../components/ModalDnsServiceInfo.tsx | 142 +++++++++++ src/types/props.d.ts | 36 ++- 23 files changed, 1076 insertions(+), 168 deletions(-) create mode 100644 src/pages/advancedVerification/components/AdvancedVerificationNotice.tsx create mode 100644 src/pages/advancedVerification/constants.ts create mode 100644 src/pages/advancedVerification/index.tsx create mode 100644 src/pages/profile/section/TelegramBanner.tsx create mode 100644 src/pages/profile/section/components/ModalDnsConfig.tsx create mode 100644 src/pages/profile/utils/wireguardConfig.ts create mode 100644 src/pages/shop/section/productList/components/ModalDnsServiceInfo.tsx diff --git a/src/App.tsx b/src/App.tsx index bbb3524..896e312 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import { Toaster } from "react-hot-toast"; import { IsAuthenticated } from "./utils/auth"; import { HelmetProvider } from "react-helmet-async"; import InActivePayment from "./pages/inActivePayment"; +import AdvancedVerification from "./pages/advancedVerification"; import { useSelector } from "react-redux"; import { RootState } from "./redux/store"; import Page404 from "./pages/404"; @@ -72,6 +73,10 @@ const App = () => { isPaymentGateway ? : } /> + } + /> } /> diff --git a/src/common/modal/index.tsx b/src/common/modal/index.tsx index 237d398..2734f1d 100644 --- a/src/common/modal/index.tsx +++ b/src/common/modal/index.tsx @@ -35,10 +35,10 @@ export default function MuiModal({ onClose={handleCloseCustom || handleClose} aria-labelledby="modal-modal-title" aria-describedby="modal-modal-description" - className="relative w-full h-full flex justify-center items-center !border-none !outline-none !ring-0" + className="relative w-full h-full flex justify-center items-center !border-none !outline-none !ring-0 overflow-y-auto p-4" > - + {children} { const dropdownRef = useRef(null); const childrenArray = Children.toArray(children); + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [setIsOpen]); + if (childrenArray.length !== 2) { console.error("ِDropDown باید دقیقاً دو فرزند داشته باشد."); return null; } const [ButtonChild, DropDownChild] = childrenArray; - const handleClickOutside = (event: MouseEvent) => { - if ( - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) - ) { - setIsOpen(false); - } - }; - - useEffect(() => { - document.addEventListener("mousedown", handleClickOutside); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, []); return (
diff --git a/src/components/progressSpace/index.tsx b/src/components/progressSpace/index.tsx index 29dd1f2..7bb3b02 100644 --- a/src/components/progressSpace/index.tsx +++ b/src/components/progressSpace/index.tsx @@ -4,8 +4,8 @@ import { ProgressSpaceProps } from "../../types/props"; const ProgressSpace = ({ startSpace, endSpace }: ProgressSpaceProps) => { let startSpacePercentage; let remaining; - let startSpaceToNumber = Number(startSpace); - let endSpaceToNumber = Number(endSpace); + const startSpaceToNumber = Number(startSpace); + const endSpaceToNumber = Number(endSpace); const isMaxSpace = startSpaceToNumber >= endSpaceToNumber; diff --git a/src/layout/LandingLayout.tsx b/src/layout/LandingLayout.tsx index 8f76afa..ee3f3a9 100644 --- a/src/layout/LandingLayout.tsx +++ b/src/layout/LandingLayout.tsx @@ -14,16 +14,15 @@ const LandingLayout = () => { if (location.hash === "#shoppingConfig") { navigate("/shopping-config"); } - }, [location]); + }, [location, navigate]); const { configs } = useParams<{ configs: string }>(); const dispatch = useDispatch(); - console.log(configs) useEffect(() => { if (configs === "1" || configs === "2" || configs === "3") { dispatch(setConfigId(configs)) } - }, [configs]); + }, [configs, dispatch]); return ( <> diff --git a/src/layout/index.tsx b/src/layout/index.tsx index 2590712..62c2c7c 100644 --- a/src/layout/index.tsx +++ b/src/layout/index.tsx @@ -35,7 +35,7 @@ const Layout = () => { dispatch(setShopId(shopId)); navigate("/"); } - }, [shopId, dispatch]); + }, [shopId, dispatch, navigate]); // useEffect(() => { // toast.custom( diff --git a/src/pages/advancedVerification/components/AdvancedVerificationNotice.tsx b/src/pages/advancedVerification/components/AdvancedVerificationNotice.tsx new file mode 100644 index 0000000..7a1093b --- /dev/null +++ b/src/pages/advancedVerification/components/AdvancedVerificationNotice.tsx @@ -0,0 +1,91 @@ +import { BiArrowBack, BiShieldQuarter } from "react-icons/bi"; +import { FaTelegram } from "react-icons/fa6"; +import { useNavigate } from "react-router-dom"; +import Button from "../../../components/button"; +import { + ADVANCED_VERIFICATION_PRICE_LIMIT, + ADVANCED_VERIFICATION_SUPPORT_URL, + buildAdvancedVerificationMessage, + formatToman, +} from "../constants"; + +interface AdvancedVerificationNoticeProps { + payablePrice?: number; + limit?: number; +} + +const AdvancedVerificationNotice = ({ + payablePrice, + limit = ADVANCED_VERIFICATION_PRICE_LIMIT, +}: AdvancedVerificationNoticeProps) => { + const navigate = useNavigate(); + const hasOrderDetails = Boolean(payablePrice || limit); + const message = buildAdvancedVerificationMessage(limit); + const orderDetailsText = [ + payablePrice ? `مبلغ سفارش شما ${formatToman(payablePrice)} است.` : "", + limit + ? `خریدهای با مبلغ بیش از ${formatToman(limit)} نیاز به احراز هویت پیشرفته دارند.` + : "", + ] + .filter(Boolean) + .join(" "); + + return ( +
+
+
+ + + +
+ +
+

+ نیاز به احراز هویت پیشرفته +

+

+ ادامه پرداخت این سفارش فعلا ممکن نیست +

+
+ +

+ {message} +

+ + {hasOrderDetails && ( +
+

جزئیات سفارش

+

+ {orderDetailsText} +

+
+ )} + +
+ + +
+
+
+ ); +}; + +export default AdvancedVerificationNotice; diff --git a/src/pages/advancedVerification/constants.ts b/src/pages/advancedVerification/constants.ts new file mode 100644 index 0000000..faa04f8 --- /dev/null +++ b/src/pages/advancedVerification/constants.ts @@ -0,0 +1,37 @@ +export const ADVANCED_VERIFICATION_PRICE_LIMIT = 20000000; +export const ADVANCED_VERIFICATION_ROUTE = "/advanced-verification"; +export const ADVANCED_VERIFICATION_SUPPORT_URL = + "https://t.me/PersianPingSupport"; + +const persianDigits: Record = { + "0": "۰", + "1": "۱", + "2": "۲", + "3": "۳", + "4": "۴", + "5": "۵", + "6": "۶", + "7": "۷", + "8": "۸", + "9": "۹", +}; + +const toPersianDigits = (value: string): string => { + return value.replace(/\d/g, (digit) => persianDigits[digit]); +}; + +const separateByThree = (num: number): string => { + return (num ?? 0).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); +}; + +export const formatToman = (amount: number): string => { + return `${toPersianDigits(separateByThree(amount))} تومان`; +}; + +export const buildAdvancedVerificationMessage = ( + limit = ADVANCED_VERIFICATION_PRICE_LIMIT +): string => { + return `به‌دلیل برخی پروتکل‌های امنیتی و به‌منظور جلوگیری از سوءاستفاده، برای خریدهای با مبلغ بیش از ${formatToman( + limit + )}، احراز هویت پیشرفته انجام می‌شود، لطفا برای ادامه فرایند با پشتیبانی تلگرام به نشانی ${ADVANCED_VERIFICATION_SUPPORT_URL} در ارتباط باشید.`; +}; diff --git a/src/pages/advancedVerification/index.tsx b/src/pages/advancedVerification/index.tsx new file mode 100644 index 0000000..cae1d73 --- /dev/null +++ b/src/pages/advancedVerification/index.tsx @@ -0,0 +1,35 @@ +import { Helmet } from "react-helmet-async"; +import { useLocation } from "react-router-dom"; +import Particle from "../../components/particle"; +import AdvancedVerificationNotice from "./components/AdvancedVerificationNotice"; + +interface AdvancedVerificationLocationState { + payablePrice?: number; + limit?: number; +} + +const AdvancedVerification = () => { + const location = useLocation(); + const state = (location.state || {}) as AdvancedVerificationLocationState; + + return ( + <> + + احراز هویت پیشرفته | پرشین پینگ + + + +
+ +
+ + ); +}; + +export default AdvancedVerification; diff --git a/src/pages/landing/section/productList/components/ListAllProduct.tsx b/src/pages/landing/section/productList/components/ListAllProduct.tsx index 02a258f..50cb627 100644 --- a/src/pages/landing/section/productList/components/ListAllProduct.tsx +++ b/src/pages/landing/section/productList/components/ListAllProduct.tsx @@ -16,28 +16,29 @@ import callof2 from "../../../../../assets/product-image/callof/cp-callofduty360 import callof3 from "../../../../../assets/product-image/callof/cp-callofduty880.png"; import callof4 from "../../../../../assets/product-image/callof/cp-callofduty2400.png"; -const ListAllProduct = ({ activeId }: { activeId: number }) => { - const allProducts = [ - [ - { id: 1, title: "پابجی 1", image: pubg1 }, - { id: 2, title: "پابجی 2", image: pubg2 }, - { id: 3, title: "پابجی 3", image: pubg3 }, - { id: 4, title: "پابجی 4", image: pubg4 }, - ], - [ - { id: 1, title: "کالاف 1", image: callof1 }, - { id: 2, title: "کالاف 2", image: callof2 }, - { id: 3, title: "کالاف 3", image: callof3 }, - { id: 4, title: "کالاف 4", image: callof4 }, - ], +const allProducts = [ + [ + { id: 1, title: "پابجی 1", image: pubg1 }, + { id: 2, title: "پابجی 2", image: pubg2 }, + { id: 3, title: "پابجی 3", image: pubg3 }, + { id: 4, title: "پابجی 4", image: pubg4 }, + ], + [ + { id: 1, title: "کالاف 1", image: callof1 }, + { id: 2, title: "کالاف 2", image: callof2 }, + { id: 3, title: "کالاف 3", image: callof3 }, + { id: 4, title: "کالاف 4", image: callof4 }, + ], - [ - { id: 1, title: "جم کلش 1", image: clash1 }, - { id: 2, title: "جم کلش 2", image: clash2 }, - { id: 3, title: "جم کلش 3", image: clash3 }, - { id: 4, title: "جم کلش 4", image: clash4 }, - ], - ]; + [ + { id: 1, title: "جم کلش 1", image: clash1 }, + { id: 2, title: "جم کلش 2", image: clash2 }, + { id: 3, title: "جم کلش 3", image: clash3 }, + { id: 4, title: "جم کلش 4", image: clash4 }, + ], +]; + +const ListAllProduct = ({ activeId }: { activeId: number }) => { const [product, setProduct] = useState(allProducts[activeId]); useEffect(() => { setProduct(allProducts[activeId]); diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index 7527531..6f02ad6 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -1,5 +1,6 @@ import ProfileDetails from "./section/ProfileDetails"; import ProductConfigList from "./section/ProductConfigList"; +import TelegramBanner from "./section/TelegramBanner"; import { useQueryParams } from "../../hooks/useQueryParams"; import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; @@ -17,23 +18,22 @@ const Profile = () => { const [message, setMessage] = useState(""); useEffect(() => { - - if (success) { - if(success === "1"){ - setIsOpen(true) - setIsPayment(true) - setMessage("موفقیت آمیز") - }else if(success === "0"){ - setIsOpen(true) - setIsPayment(false) - setMessage("خطا") - }else{ - toast.error("متاسفانه کد پرداخت به درستی دریافت نشد") - } - } else { - console.error("Track ID is missing or invalid."); + if (success === null) { + return; } - }, []); + + if (success === "1") { + setIsOpen(true); + setIsPayment(true); + setMessage("موفقیت آمیز"); + } else if (success === "0") { + setIsOpen(true); + setIsPayment(false); + setMessage("خطا"); + } else { + toast.error("متاسفانه کد پرداخت به درستی دریافت نشد"); + } + }, [success]); const { data, isLoading, isFetching, error } = useQuery({ queryKey: ["profile"], @@ -58,6 +58,7 @@ const Profile = () => { isFetching={isFetching} error={error} /> + { + const configs = data?.result?.configs; + const showInitialLoading = (isLoading || isFetching) && !data; + return (
- {data?.result?.configs?.length !== 0 && ( + {configs?.length !== 0 && (

لیست کانفیگ ها

@@ -40,7 +43,7 @@ const ProductConfigList = ({ className="w-full h-full p-3 max-sm:px-0 flex flex-col gap-3 justify-center items-center" > - {isLoading || isFetching ? ( + {showInitialLoading ? ( Array(3) .fill(null) .map((_, i) => ( @@ -52,7 +55,7 @@ const ProductConfigList = ({ {error.message} - ) : data?.result?.configs?.length === 0 ? ( + ) : configs?.length === 0 ? (
) : ( - data?.result?.configs?.map((item) => { + configs?.map((item, index) => { return ( - + ); diff --git a/src/pages/profile/section/TelegramBanner.tsx b/src/pages/profile/section/TelegramBanner.tsx new file mode 100644 index 0000000..27ce8b6 --- /dev/null +++ b/src/pages/profile/section/TelegramBanner.tsx @@ -0,0 +1,24 @@ +import { FaTelegram } from "react-icons/fa"; + +const TELEGRAM_CHANNEL = "https://t.me/PersianPing"; + +const TelegramBanner = () => { + return ( + +
+ + عضویت در کانال تلگرام +
+

+ برای دریافت آخرین اخبار و اطلاعیه‌ها حتماً در کانال تلگرام ما عضو باشید. +

+
+ ); +}; + +export default TelegramBanner; diff --git a/src/pages/profile/section/components/ConfigItem.tsx b/src/pages/profile/section/components/ConfigItem.tsx index 6a6f7e4..0b7c2a7 100644 --- a/src/pages/profile/section/components/ConfigItem.tsx +++ b/src/pages/profile/section/components/ConfigItem.tsx @@ -1,7 +1,8 @@ -import { IoCopy } from "react-icons/io5"; +import { IoCopy, IoDownload } from "react-icons/io5"; import Button from "../../../../components/button"; import { ConfigItemProps } from "../../../../types/props"; import ModalConfigDetails from "./ModalConfigDetails"; +import ModalDnsConfig from "./ModalDnsConfig"; import { useEffect, useState } from "react"; import Dropdown, { DropdownItem } from "../../../../components/dropdown"; import ProgressSpace from "../../../../components/progressSpace"; @@ -12,9 +13,11 @@ import { setCategoryId } from "../../../../redux/slices/categoryId"; import { useQuery } from "@tanstack/react-query"; import useAxios from "../../../../hooks/useAxios"; import LoaderSpaceConfig from "./LoaderSpaceConfig"; +import { downloadWireGuardConfig } from "../../utils/wireguardConfig"; const ConfigItem = ({ id, + orderId, dateBuy, configName, isDisabled, @@ -22,9 +25,17 @@ const ConfigItem = ({ textCopy, type, categoryId, - useMaxTraffic + useMaxTraffic, + username: configUsername, + primaryIpv4, + secondaryIpv4, + primaryIpv6, + secondaryIpv6, + dnsIpPending, + dnsRegistrationExpired, }: ConfigItemProps) => { const [isOpen, setIsOpen] = useState(false); + const [isOpenDnsModal, setIsOpenDnsModal] = useState(false); const [isOpenDropDown, setIsOpenDropDown] = useState(false); const [configId, setConfigId] = useState(""); const dispatch = useDispatch(); @@ -49,12 +60,24 @@ const ConfigItem = ({ setIsOpen(true); } }; + const configType = type ?? ""; + const isWireGuard = configType.toLowerCase() === "wireguard"; + const isDns = configType.toLowerCase() === "dns"; + const downloadWireGuardFile = () => { + if (!textCopy) { + toast.error("کانفیگ وایرگارد موجود نیست"); + return; + } + + downloadWireGuardConfig(textCopy, configUsername); + toast.success("فایل کانفیگ دانلود شد"); + }; const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); useEffect(() => { - if (type === "gaming" && textCopy) { + if (configType === "gaming" && textCopy) { const [user, pass] = textCopy.split(","); setUsername(user); setPassword(pass); @@ -62,7 +85,7 @@ const ConfigItem = ({ setUsername(""); setPassword(""); } - }, [textCopy, type]); + }, [configType, textCopy]); const queryUpdateConfig = useQuery({ queryKey: ["updateConfig", isOpenDropDown || isOpen], @@ -98,6 +121,7 @@ const ConfigItem = ({ type={type} username={username} password={password} + wireguardUsername={configUsername} categoryId={categoryId} isLoadSpaceConfig={ queryUpdateConfig.isLoading || queryUpdateConfig.isFetching @@ -105,6 +129,20 @@ const ConfigItem = ({ error={queryUpdateConfig?.error ? queryUpdateConfig?.error : null} useMaxTraffic={useMaxTraffic} /> + {isDns && ( + + )}
{isDisabled || useMaxTraffic && (
@@ -129,7 +167,14 @@ const ConfigItem = ({ {configName}
- {type === "gaming" ? ( + {isDns ? ( + + ) : configType === "gaming" ? ( <> ) : ( - + <> + {isWireGuard ? ( + + ) : ( + + )} + )}
-
- +
+ {isDns && ( + + )} + {!isDns && ( + + )}
diff --git a/src/pages/profile/section/components/ModalConfigDetails.tsx b/src/pages/profile/section/components/ModalConfigDetails.tsx index 2c05960..8c64acb 100644 --- a/src/pages/profile/section/components/ModalConfigDetails.tsx +++ b/src/pages/profile/section/components/ModalConfigDetails.tsx @@ -1,7 +1,7 @@ import { Dispatch, SetStateAction, useState } from "react"; import MuiModal from "../../../../common/modal"; import Button from "../../../../components/button"; -import { IoCopy } from "react-icons/io5"; +import { IoCopy, IoDownload } from "react-icons/io5"; import Alert from "../../../../components/alert/Alert"; import ProgressSpace from "../../../../components/progressSpace"; import toast from "react-hot-toast"; @@ -11,6 +11,7 @@ import { setCategoryId } from "../../../../redux/slices/categoryId"; import Input from "../../../../components/input"; import LoaderSpaceConfig from "./LoaderSpaceConfig"; import { AxiosError } from "axios"; +import { downloadWireGuardConfig } from "../../utils/wireguardConfig"; const ModalConfigDetails = ({ isOpen, @@ -25,6 +26,7 @@ const ModalConfigDetails = ({ type, username, password, + wireguardUsername, categoryId, isLoadSpaceConfig, error, @@ -39,18 +41,20 @@ const ModalConfigDetails = ({ textCopy: string; maxTraffic: number | "unlimited"; traffic: number; - type: string; + type: string | null; username: string; password: string; + wireguardUsername?: string | null; categoryId: number; isLoadSpaceConfig: boolean; - error: AxiosError | any; + error: AxiosError | Error | null; useMaxTraffic: boolean; }) => { const navigate = useNavigate(); const dispatch = useDispatch(); - const [_, setCopyText] = useState(""); const [isCopyError, setIsCopyError] = useState(false); + const configType = type ?? ""; + const isWireGuard = configType.toLowerCase() === "wireguard"; const copyToClipboard = async (textToCopy: string) => { try { @@ -62,6 +66,16 @@ const ModalConfigDetails = ({ toast.error(`Failed to copy: , ${err}`); } }; + const downloadWireGuardFile = () => { + if (!textCopy) { + toast.error("کانفیگ وایرگارد موجود نیست"); + return; + } + + downloadWireGuardConfig(textCopy, wireguardUsername); + toast.success("فایل کانفیگ دانلود شد"); + }; + return ( ) : ( <> - {type === "gaming" ? ( + {configType === "gaming" ? ( <> {isCopyError ? (
@@ -112,7 +126,6 @@ const ModalConfigDetails = ({ <>
) : ( - + <> + {isWireGuard ? ( + + ) : ( + + )} + )} )} diff --git a/src/pages/profile/section/components/ModalDnsConfig.tsx b/src/pages/profile/section/components/ModalDnsConfig.tsx new file mode 100644 index 0000000..4789437 --- /dev/null +++ b/src/pages/profile/section/components/ModalDnsConfig.tsx @@ -0,0 +1,239 @@ +import { useState } from "react"; +import toast from "react-hot-toast"; +import { IoCopyOutline, IoWifiOutline } from "react-icons/io5"; +import { MdOutlineDns } from "react-icons/md"; +import { FaBolt } from "react-icons/fa6"; +import { BsShieldExclamation } from "react-icons/bs"; +import { useMutation } from "@tanstack/react-query"; +import { AxiosError } from "axios"; +import MuiModal from "../../../../common/modal"; +import Button from "../../../../components/button"; +import Input from "../../../../components/input"; +import Alert from "../../../../components/alert/Alert"; +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 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 getErrorMessage = (err: AxiosError) => { + const responseData = err.response?.data; + if ( + responseData && + typeof responseData === "object" && + "alert" in responseData + ) { + return (responseData as { alert: string }).alert; + } + return "یک خطای غیرمنتظره رخ داد"; +}; + +const ModalDnsConfig = ({ + isOpen, + setIsOpen, + nameConfig, + orderId, + primaryIpv4, + secondaryIpv4, + primaryIpv6, + secondaryIpv6, + dnsIpPending, + dnsRegistrationExpired, +}: ModalDnsConfigProps) => { + const axios = useAxios(); + const [ip, setIp] = useState(""); + const [isDetecting, setIsDetecting] = useState(false); + + const copyToClipboard = async (label: string, textToCopy?: string | null) => { + if (!textToCopy) return; + try { + await navigator.clipboard.writeText(textToCopy); + toast.success(`${label} کپی شد!`); + } catch (err) { + toast.error(`کپی انجام نشد: ${err}`); + } + }; + + const registerIpMutation = useMutation({ + mutationKey: ["registerDnsIp", orderId], + mutationFn: (ipToRegister: string) => { + return axios.post(`/profile/dns/${orderId}/register-ip`, { + ip: ipToRegister, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["profile"] }); + }, + }); + + const registerIp = async (ipToRegister: string) => { + const normalizedIp = ipToRegister.trim(); + if (!orderId) { + toast.error("شناسه سفارش نامعتبر است"); + return; + } + if (!IP_ADDRESS_REGEX.test(normalizedIp)) { + toast.error("آی‌پی وارد شده معتبر نیست"); + return; + } + setIp(normalizedIp); + try { + await toast.promise(registerIpMutation.mutateAsync(normalizedIp), { + loading: "درحال ذخیره آی‌پی...", + success: (res) => res?.data?.alert || "آی‌پی با موفقیت ذخیره شد", + error: (err: AxiosError) => getErrorMessage(err), + }); + } catch { + return; + } + }; + + 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 detectedIp = await endpoint.parse(res); + if (detectedIp && IP_ADDRESS_REGEX.test(detectedIp)) { + setIp(detectedIp); + toast.success("آی‌پی کاربر دریافت شد"); + return; + } + } catch { + continue; + } + } + + toast.error("دریافت آی‌پی خودکار ممکن نشد"); + } catch (err) { + toast.error(`دریافت آی‌پی خودکار با خطا مواجه شد: ${err}`); + } finally { + setIsDetecting(false); + } + }; + + const ipRows: { label: string; badge: string; value?: string | null }[] = [ + { label: "DNS نسل 4", badge: "v4", value: primaryIpv4 }, + { label: "DNS نسل 4", badge: "v4", value: secondaryIpv4 }, + { label: "DNS نسل 6", badge: "v6", value: primaryIpv6 }, + { label: "DNS نسل 6", badge: "v6", value: secondaryIpv6 }, + ].filter((row) => row.value); + + return ( + +
+ {dnsRegistrationExpired && ( + + مهلت ثبت آی‌پی برای این سرویس به پایان رسیده است + + )} + {dnsIpPending && !dnsRegistrationExpired && ( + + ثبت آی‌پی این سرویس در انتظار تایید است + + )} + + {ipRows.length > 0 && ( +
+
+ + آدرس‌های DNS +
+
+ {ipRows.map((row, index) => ( +
+
+
+ + {row.badge} + + {row.label} +
+ +
+
+ {row.value} +
+
+ ))} +
+
+ )} + +
+ + + در صورت روشن بودن فیلترشکن (VPN)، برای ثبت صحیح آی‌پی، لطفا ابتدا آن را خاموش کنید. + +
+ +
+
+ + ثبت آی‌پی +
+
+
+ setIp(e.target.value)} + extra="!text-start" + /> +
+ +
+ +
+
+
+ ); +}; + +export default ModalDnsConfig; diff --git a/src/pages/profile/utils/wireguardConfig.ts b/src/pages/profile/utils/wireguardConfig.ts new file mode 100644 index 0000000..fd4de27 --- /dev/null +++ b/src/pages/profile/utils/wireguardConfig.ts @@ -0,0 +1,71 @@ +const WIREGUARD_FIELDS = [ + "Address", + "PrivateKey", + "DNS", + "MTU", + "PublicKey", + "AllowedIPs", + "Endpoint", +]; + +export const formatWireGuardConfig = (config: string) => { + const fieldPattern = new RegExp( + `\\s+(${WIREGUARD_FIELDS.join("|")})\\s*=`, + "g" + ); + + const lines = config + .replace(/\r\n?/g, "\n") + .replace(/\s*(\[(?:Interface|Peer)\])\s*/g, "\n$1\n") + .replace(fieldPattern, "\n$1 =") + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + + const formattedLines = lines.reduce((result, line) => { + if (line.startsWith("[") && result.length > 0) { + result.push(""); + } + + result.push(line); + return result; + }, []); + + return `${formattedLines.join("\n")}\n`; +}; + +export const getWireGuardConfigFileName = ( + username: string | null | undefined +) => { + const randomTwoDigitNumber = Math.floor(Math.random() * 90) + 10; + const defaultName = `persianping${randomTwoDigitNumber}`; + const baseName = (username?.trim() || defaultName) + .trim() + .replace(/[\\/:*?"<>|]+/g, "-") + .replace(/\s+/g, "-"); + + const safeName = baseName || defaultName; + + return safeName.toLowerCase().endsWith(".conf") + ? safeName + : `${safeName}.conf`; +}; + +export const downloadWireGuardConfig = ( + config: string, + username: string | null | undefined +) => { + const fileName = getWireGuardConfigFileName(username); + const file = new Blob([formatWireGuardConfig(config)], { + type: "application/octet-stream", + }); + const url = URL.createObjectURL(file); + const link = document.createElement("a"); + + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +}; diff --git a/src/pages/rule/section/Rules.tsx b/src/pages/rule/section/Rules.tsx index bdab2ac..3d4ed00 100644 --- a/src/pages/rule/section/Rules.tsx +++ b/src/pages/rule/section/Rules.tsx @@ -60,6 +60,14 @@ const ListRules = () => { نخواهد شد.

+
  • + warning +

    + به‌دلیل برخی پروتکل‌های امنیتی و به‌منظور جلوگیری از سوءاستفاده، + برای خریدهای با مبلغ بیش از ۲۰ میلیون تومان، احراز هویت پیشرفته + انجام می‌شود. +

    +
  • warning

    diff --git a/src/pages/shop/section/productList/ProductList.tsx b/src/pages/shop/section/productList/ProductList.tsx index cc11d31..2701bc7 100644 --- a/src/pages/shop/section/productList/ProductList.tsx +++ b/src/pages/shop/section/productList/ProductList.tsx @@ -53,7 +53,10 @@ const ProductList = () => { activeId={activeId} setActiveId={setActiveId} /> - +

  • ); diff --git a/src/pages/shop/section/productList/components/ListAllProduct.tsx b/src/pages/shop/section/productList/components/ListAllProduct.tsx index 1f0d093..6365cd4 100644 --- a/src/pages/shop/section/productList/components/ListAllProduct.tsx +++ b/src/pages/shop/section/productList/components/ListAllProduct.tsx @@ -9,11 +9,25 @@ import { RootState } from "../../../../../redux/store"; import { useNavigate } from "react-router-dom"; import toast from "react-hot-toast"; -const ListAllProduct = ({ activeId }: { activeId: number }) => { +interface Category { + id: number | string; + label?: string; +} + +const ListAllProduct = ({ + activeId, + categories, +}: { + activeId: number; + categories?: Category[]; +}) => { const axios = useAxios(); const [selectedConfigId, setSelectedConfigId] = useState(null); const { token } = useSelector((state: RootState) => state.token); const navigate = useNavigate(); + const isDnsCategory = + categories?.find((category) => Number(category.id) === activeId)?.label?.toLowerCase() === + "dns"; const { data, error, isLoading, isFetching } = useQuery({ queryKey: ["configs", activeId], queryFn: () => { @@ -70,6 +84,7 @@ const ListAllProduct = ({ activeId }: { activeId: number }) => { isOpen={selectedConfigId !== null} setIsOpen={(open) => { if (!open) setSelectedConfigId(null); }} configId={selectedConfigId} + isDns={isDnsCategory} /> )}
    { - const axios = useAxios(); - const dispatch = useDispatch(); - const navigate = useNavigate(); - const separateByThree = (num: number): string => { - return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); +const ModalBuy = ({ isOpen, setIsOpen, configId, isDns }: ModalBuyProps) => { + const axios = useAxios(); + const dispatch = useDispatch(); + const navigate = useNavigate(); + const separateByThree = (num: number): string => { + return (num ?? 0).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); + }; + const toNumber = (value: unknown): number => { + const normalizedValue = + typeof value === "string" ? value.replace(/,/g, "") : value; + const parsedValue = Number(normalizedValue); + return Number.isFinite(parsedValue) ? parsedValue : 0; }; const { data, error, isLoading, isFetching, refetch } = useQuery({ queryKey: ["configSee", configId], @@ -32,29 +44,59 @@ const ModalBuy = ({ isOpen, setIsOpen, configId }: ModalBuyProps) => { enabled: isOpen, }); const [price, setPrice] = useState(0); + const [isInfoOpen, setIsInfoOpen] = useState(false); + const payablePrice = toNumber( + price === 0 ? data?.data?.result?.price || 0 : price + ); + const advancedVerificationLimit = toNumber( + data?.data?.result?.advanced_verification_limit ?? + ADVANCED_VERIFICATION_PRICE_LIMIT + ); + const advancedVerificationMessage = + buildAdvancedVerificationMessage(advancedVerificationLimit); + const advancedVerificationFromApi = + data?.data?.result?.requires_advanced_verification; + const hasAdvancedVerificationApiFlag = + advancedVerificationFromApi !== undefined && + advancedVerificationFromApi !== null; + const requiresAdvancedVerification = + hasAdvancedVerificationApiFlag + ? Boolean(advancedVerificationFromApi) + : payablePrice > advancedVerificationLimit; + const goToAdvancedVerification = () => { + setIsOpen(false); + navigate(ADVANCED_VERIFICATION_ROUTE, { + state: { + payablePrice, + limit: advancedVerificationLimit, + }, + }); + }; const formik = useFormik({ - initialValues: { - count: 1, - discount: "", - status: "view", - }, - validationSchema: Yup.object({ - count: Yup.number().min(1, "حداقل یک کانفیگ باید باشد"), - discount: Yup.string(), - status: Yup.string(), - }), + initialValues: { + count: 1, + discount: "", + status: "view", + }, + validationSchema: Yup.object({ + count: Yup.number().min(1, "حداقل یک کانفیگ باید باشد"), + discount: Yup.string(), + status: Yup.string(), + }), onSubmit: (values) => { console.log(values); }, }); + const { setFieldValue } = formik; + useEffect(() => { - if (!isOpen) { - formik.setFieldValue("count", 1); - formik.setFieldValue("discount", ""); - setPrice(0); - } - }, [isOpen]); + if (!isOpen) { + setFieldValue("count", 1); + setFieldValue("discount", ""); + setPrice(0); + } + }, [isOpen, setFieldValue]); const mutation = useMutation({ mutationKey: ["buyConfig"], mutationFn: (values: BuyConfigDataProps) => { @@ -81,7 +123,8 @@ const ModalBuy = ({ isOpen, setIsOpen, configId }: ModalBuyProps) => { return `${res.data.alert}`; }, error: (err: AxiosError) => { - formik.setFieldValue("discount", ""); + setFieldValue("discount", ""); + setPrice(0); let errorMessage = "یک خطای غیرمنتظره رخ داد"; if ( err.response?.data && @@ -94,7 +137,8 @@ const ModalBuy = ({ isOpen, setIsOpen, configId }: ModalBuyProps) => { }, }); }; - const chnageCountDebouncedMutation = useCallback( + const chnageCountDebouncedMutation = useMemo( + () => debounce((values) => { toast.promise(mutation.mutateAsync(values), { loading: "درحال بررسی...", @@ -103,7 +147,8 @@ const ModalBuy = ({ isOpen, setIsOpen, configId }: ModalBuyProps) => { return `${res.data.alert}`; }, error: (err: AxiosError) => { - formik.setFieldValue("discount", ""); + setFieldValue("discount", ""); + setPrice(0); refetch(); let errorMessage = "یک خطای غیرمنتظره رخ داد"; if ( @@ -117,17 +162,17 @@ const ModalBuy = ({ isOpen, setIsOpen, configId }: ModalBuyProps) => { }, }); }, 1000), - [] + [mutation, refetch, setFieldValue] ); const chnageCountHandler = (status: "inc" | "dec") => { if (status === "inc") { - formik.setFieldValue("count", formik.values.count + 1); + setFieldValue("count", formik.values.count + 1); chnageCountDebouncedMutation({ ...formik.values, count: formik.values.count + 1, }); } else if (status === "dec") { - formik.setFieldValue("count", formik.values.count - 1); + setFieldValue("count", formik.values.count - 1); chnageCountDebouncedMutation({ ...formik.values, count: formik.values.count - 1, @@ -136,7 +181,12 @@ const ModalBuy = ({ isOpen, setIsOpen, configId }: ModalBuyProps) => { }; const submitHandler = () => { - console.log(formik.values); + if (requiresAdvancedVerification) { + goToAdvancedVerification(); + return; + } + + console.log(formik.values); toast.promise(mutation.mutateAsync({ ...formik.values, status: "buy" }), { loading: "درحال انتقال به درگاه پرداخت...", success: (res: AxiosResponse) => { @@ -144,31 +194,62 @@ const ModalBuy = ({ isOpen, setIsOpen, configId }: ModalBuyProps) => { return `${res.data.alert}`; }, error: (err: AxiosError) => { - formik.setFieldValue("discount", ""); - let errorMessage = "یک خطای غیرمنتظره رخ داد"; - if ( - err.response?.data && - typeof err.response.data === "object" && - "alert" in err.response.data - ) { - errorMessage = (err.response.data as { alert: string }).alert; - } - toast.error(`خطایی رخ داد : ${errorMessage}`); - dispatch(setIsPaymentGateway(false)); - navigate("/noactive-payment"); - return errorMessage; - }, + setFieldValue("discount", ""); + setPrice(0); + let errorMessage = "یک خطای غیرمنتظره رخ داد"; + if ( + err.response?.data && + typeof err.response.data === "object" && + "alert" in err.response.data + ) { + errorMessage = (err.response.data as { alert: string }).alert; + } + + const responseData = err.response?.data; + const isResponseObject = + responseData !== null && typeof responseData === "object"; + const requiresAdvancedVerificationFromApi = + isResponseObject && + (("requires_advanced_verification" in responseData && + Boolean( + (responseData as { requires_advanced_verification?: boolean }) + .requires_advanced_verification + )) || + ("code" in responseData && + (responseData as { code?: string }).code === + "ADVANCED_VERIFICATION_REQUIRED")); + const isAdvancedVerificationError = + requiresAdvancedVerificationFromApi || + (err.response?.status === 422 && + errorMessage.includes("احراز هویت پیشرفته")); + + if (isAdvancedVerificationError) { + goToAdvancedVerification(); + } + + if (err.response?.status === 500) { + dispatch(setIsPaymentGateway(false)); + navigate("/noactive-payment"); + } + + return errorMessage; + }, }); }; return ( + <> + {
    ) : (
    + {isDns && ( + + )} {" "} کاربر گرامی لطفا از خاموش بودن vpn خود اطمینان حاصل فرمایید تا در @@ -224,11 +314,15 @@ const ModalBuy = ({ isOpen, setIsOpen, configId }: ModalBuyProps) => {

    مبلغ قابل پرداخت :{" "} - {separateByThree( - price === 0 ? data?.data?.result?.price || 0 : price - )} + {separateByThree(payablePrice)}

    + {requiresAdvancedVerification && + data?.data?.result?.max_config !== 0 && ( + + {advancedVerificationMessage} + + )} {data?.data?.result?.max_config === 0 ? ( <> @@ -238,7 +332,7 @@ const ModalBuy = ({ isOpen, setIsOpen, configId }: ModalBuyProps) => { ) : ( <> -

    +

    در صورت نداشتن کد تخفیف پرداخت رو بزنید

    {

    محدودیت کاربر

    -

    {data?.data?.result?.limit ?? "بدون محدودیت کاربر"}

    + {!data?.data?.result?.limit ? ( +

    نامحدود

    + ) : ( +

    {data?.data?.result?.limit}

    + )}

    مدت زمان

    @@ -318,8 +416,15 @@ const ModalBuy = ({ isOpen, setIsOpen, configId }: ModalBuyProps) => {
    )} + {data?.data?.result?.max_config !== 0 && !isLoading && !isFetching && ( +

    + با خرید، قوانین و مقررات را + می‌پذیرید. +

    + )}
    + ); }; diff --git a/src/pages/shop/section/productList/components/ModalDnsServiceInfo.tsx b/src/pages/shop/section/productList/components/ModalDnsServiceInfo.tsx new file mode 100644 index 0000000..da6d913 --- /dev/null +++ b/src/pages/shop/section/productList/components/ModalDnsServiceInfo.tsx @@ -0,0 +1,142 @@ +import { Dispatch, SetStateAction } from "react"; +import MuiModal from "../../../../../common/modal"; + +interface ModalDnsServiceInfoProps { + isOpen: boolean; + setIsOpen: Dispatch>; +} + +const GAMES = [ + "Valorant", + "Call of Duty: Warzone", + "EA SPORTS FC 25", + "EA SPORTS FC 26", + "PUBG Mobile", + "PUBG: Battlegrounds (PC)", + "Call of Duty: Mobile (CODM)", + "Fortnite", + "League of Legends (LoL)", + "Clash Royale", + "Clash of Clans", +]; + +const SERVICES = [ + "ChatGPT", + "Discord", + "Spotify", + "NVIDIA (App & Services)", + "GitHub، Docker، NPM، PyPI و بسیاری از ابزارها و سرویس‌های توسعه (Developer Services)", +]; + +const STREAMING = ["YouTube", "Twitch", "Kick"]; + +const BENEFITS = [ + "بدون نیاز به VPN", + "راه‌اندازی در کمتر از یک دقیقه", + "مناسب برای کامپیوتر، موبایل و کنسول بازی", + "پشتیبانی و بروزرسانی مداوم سرویس‌ها", +]; + +const Section = ({ + emoji, + title, + children, +}: { + emoji: string; + title: string; + children: React.ReactNode; +}) => ( +
    +
    + {emoji} + {title} +
    + {children} +
    +); + +const ModalDnsServiceInfo = ({ isOpen, setIsOpen }: ModalDnsServiceInfoProps) => { + return ( + +
    +
    +
      + {GAMES.map((game) => ( +
    • + 🔹 + + {game} + +
    • + ))} +
    +
    + +
    +
      + {SERVICES.map((service) => ( +
    • + ✔️ + {service} +
    • + ))} +
    +
    + +
    +
      + {STREAMING.map((item) => ( +
    • +
      + ▶️ + {item} +
      + + فقط روی اینترنت خانگی + +
    • + ))} +
    +
    + +
    +
      + {BENEFITS.map((benefit) => ( +
    • + ✅ + {benefit} +
    • + ))} +
    +
    + +
    + 📌 + + بازی و سرویس‌های جدید به‌صورت مداوم به DNS اضافه می‌شوند و این + لیست همواره در حال بروزرسانی است. + +
    +
    +
    + ); +}; + +export default ModalDnsServiceInfo; diff --git a/src/types/props.d.ts b/src/types/props.d.ts index b8bf089..519fe3c 100644 --- a/src/types/props.d.ts +++ b/src/types/props.d.ts @@ -121,14 +121,35 @@ export interface MuiModalProps { export interface ConfigItemProps { id: string; + orderId?: number; dateBuy: string; configName: string; isDisabled: boolean; isShowTraffic: boolean; textCopy: string; - type: string; + type: string | null; categoryId: number; useMaxTraffic: boolean; + username?: string | null; + primaryIpv4?: string | null; + secondaryIpv4?: string | null; + primaryIpv6?: string | null; + secondaryIpv6?: string | null; + dnsIpPending?: boolean; + dnsRegistrationExpired?: boolean; +} + +export interface ModalDnsConfigProps { + isOpen: boolean; + setIsOpen: Dispatch>; + nameConfig: string; + orderId?: number; + primaryIpv4?: string | null; + secondaryIpv4?: string | null; + primaryIpv6?: string | null; + secondaryIpv6?: string | null; + dnsIpPending?: boolean; + dnsRegistrationExpired?: boolean; } // نوع Item برای آیتم‌های داخل Dropdown @@ -178,6 +199,7 @@ interface ModalBuyProps { // unknown // >; configId: number; + isDns?: boolean; } interface BuyConfigDataProps { @@ -195,17 +217,25 @@ type UserProfile = { // تعریف تایپ پیکربندی‌ها type Config = { - id: number; + id: number | string; + order_id?: number; title: string; url: string; created_at: string | null; show_traffic: boolean; max_traffic: number; traffic: number; - type: string; + type: string | null; + username?: string | null; category_id: number; is_expired: boolean; use_max_traffic: boolean; + primary_ipv4?: string | null; + secondary_ipv4?: string | null; + primary_ipv6?: string | null; + secondary_ipv6?: string | null; + dns_ip_pending?: boolean; + dns_registration_expired?: boolean; }; // تعریف تایپ داده‌های برگشتی