feat: add can_renew
This commit is contained in:
@@ -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<string>("all");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("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 (
|
||||
<div className="w-full h-full p-5 glass flex flex-col gap-3">
|
||||
<ProfileImage />
|
||||
@@ -39,6 +52,17 @@ const ProductConfigList = ({
|
||||
لیست کانفیگ ها
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!showInitialLoading && !error && configs && configs.length > 0 && (
|
||||
<ConfigFilterBar
|
||||
configs={configs}
|
||||
statusFilter={statusFilter}
|
||||
setStatusFilter={setStatusFilter}
|
||||
typeFilter={typeFilter}
|
||||
setTypeFilter={setTypeFilter}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="w-full h-full p-3 max-sm:px-0 flex flex-col gap-3
|
||||
justify-center items-center"
|
||||
@@ -68,8 +92,15 @@ const ProductConfigList = ({
|
||||
<Button>برو به فروشگاه</Button>
|
||||
</div>
|
||||
</Link>
|
||||
) : filteredConfigs?.length === 0 ? (
|
||||
<div className="w-full h-full flex flex-col gap-3 justify-center items-center py-5">
|
||||
<IoAlertCircleSharp className="max-md:w-[2.5rem] max-md:h-[2.5rem] w-[3.5rem] h-[3.5rem]" />
|
||||
<p className="text-sm md:text-base">
|
||||
با این فیلتر سرویسی پیدا نشد
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
configs?.map((item, index) => {
|
||||
filteredConfigs?.map((item, index) => {
|
||||
return (
|
||||
<Fragment key={`${item.id}-${index}`}>
|
||||
<ConfigItem
|
||||
@@ -90,6 +121,8 @@ const ProductConfigList = ({
|
||||
secondaryIpv6={item.secondary_ipv6}
|
||||
dnsIpPending={item.dns_ip_pending}
|
||||
dnsRegistrationExpired={item.dns_registration_expired}
|
||||
dnsExpiresAt={item.dns_expires_at}
|
||||
canRenew={item.can_renew}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
143
src/pages/profile/section/components/ConfigFilterBar.tsx
Normal file
143
src/pages/profile/section/components/ConfigFilterBar.tsx
Normal file
@@ -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: <IoAppsOutline />,
|
||||
activeClass: "!bg-secondTheme !text-gray-900",
|
||||
},
|
||||
active: {
|
||||
label: "فعال",
|
||||
icon: <IoCheckmarkCircle />,
|
||||
activeClass: "!bg-green-600 !text-white",
|
||||
},
|
||||
expired: {
|
||||
label: "منقضی شده",
|
||||
icon: <IoCloseCircle />,
|
||||
activeClass: "!bg-rose-600 !text-white",
|
||||
},
|
||||
pending: {
|
||||
label: "در انتظار ثبت",
|
||||
icon: <IoTimeOutline />,
|
||||
activeClass: "!bg-yellow-400 !text-gray-900",
|
||||
},
|
||||
};
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
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<Record<string, number>>((acc, item) => {
|
||||
if (item.status) acc[item.status] = (acc[item.status] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const availableStatuses = Object.keys(statusCounts);
|
||||
|
||||
const typeCounts = configs.reduce<Record<string, number>>((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 (
|
||||
<div className="w-full flex flex-col gap-3 bg-gray-900/60 rounded-xl p-3 shadow-md shadow-gray-900">
|
||||
{availableStatuses.length > 1 && (
|
||||
<div className="w-full flex flex-wrap gap-2 justify-center">
|
||||
{["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 (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(status)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs md:text-sm font-bold
|
||||
transition-all duration-200 shadow-md
|
||||
${isActive
|
||||
? meta.activeClass
|
||||
: "bg-gray-800 text-gray-300 hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
{meta.icon}
|
||||
{meta.label}
|
||||
<span className="opacity-70">({count})</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{availableTypes.length > 1 && (
|
||||
<div
|
||||
className={`w-full flex flex-wrap gap-2 justify-center ${availableStatuses.length > 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 (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setTypeFilter(type)}
|
||||
className={`px-3 py-1 rounded-full text-[11px] md:text-xs font-bold transition-all duration-200
|
||||
${isActive
|
||||
? "!bg-secondTheme !text-gray-900 shadow-md"
|
||||
: "bg-gray-800/70 text-gray-400 hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
{type === "all" ? "همه سرویسها" : typeLabel(type)}
|
||||
<span className="opacity-70"> ({count})</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigFilterBar;
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
<div className="relative w-full h-full rounded-md">
|
||||
|
||||
@@ -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)) {
|
||||
if (detectedIp && isValidIpv4Address(detectedIp)) {
|
||||
setIp(detectedIp);
|
||||
toast.success("آیپی کاربر دریافت شد");
|
||||
toast.success(res.data?.alert || "آیپی نسخه ۴ کاربر دریافت شد");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
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 = ({
|
||||
<div className="w-full h-full flex flex-col gap-4 max-sm:text-xs max-md:text-sm text-base">
|
||||
{dnsRegistrationExpired && (
|
||||
<Alert type="danger" extra="!mb-0 !rounded-lg">
|
||||
مهلت ثبت آیپی برای این سرویس به پایان رسیده است
|
||||
مهلت این سرویس به پایان رسیده است. برای ادامه استفاده، سرویس را تمدید کنید
|
||||
</Alert>
|
||||
)}
|
||||
{dnsIpPending && !dnsRegistrationExpired && (
|
||||
@@ -152,6 +169,32 @@ const ModalDnsConfig = ({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{canRenew && (
|
||||
<div className="w-full flex flex-row-reverse items-center justify-between gap-2 bg-gray-900/60 border border-gray-700 rounded-lg p-3">
|
||||
<div className="text-start">
|
||||
{dnsExpiresAt ? (
|
||||
<span className="text-gray-300 text-xs md:text-sm">
|
||||
مهلت سرویس تا{" "}
|
||||
<span className="text-secondTheme font-bold" dir="ltr">
|
||||
{new Date(dnsExpiresAt).toLocaleDateString("fa-IR")}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-300 text-xs md:text-sm">
|
||||
تمدید سرویس DNS
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleRenew}
|
||||
disabled={renewMutation.isPending}
|
||||
extra="!m-0 !px-3 shadow-md shrink-0 !bg-secondTheme !text-gray-900 font-bold disabled:!opacity-50"
|
||||
>
|
||||
{renewMutation.isPending ? "درحال انتقال..." : "تمدید سرویس"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ipRows.length > 0 && (
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<div className="flex flex-row-reverse items-center gap-1.5 text-secondTheme font-bold">
|
||||
|
||||
7
src/types/props.d.ts
vendored
7
src/types/props.d.ts
vendored
@@ -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;
|
||||
};
|
||||
|
||||
// تعریف تایپ دادههای برگشتی
|
||||
|
||||
Reference in New Issue
Block a user