feat: add can_renew

This commit is contained in:
2026-09-01 18:50:58 +03:30
parent 9fd8543b7e
commit f16b9044a1
5 changed files with 266 additions and 36 deletions

View File

@@ -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>
);

View 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;

View File

@@ -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">

View File

@@ -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 = ({
<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">

View File

@@ -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;
};
// تعریف تایپ داده‌های برگشتی