Compare commits

...

10 Commits

Author SHA1 Message Date
29d66a1621 Add robots.txt and sitemap with correct public routes.
Serve only indexable pages to crawlers and copy the SEO files into the Vite/Docker build output.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-02 01:08:42 +03:30
ae66644321 Use Arvan registry for frontend base images 2026-10-18 00:01:41 +03:30
e669ab84d9 Load terms dynamically and link checkout to terms page 2026-10-18 00:01:41 +03:30
c4c115ccfc Lock verified identity fields in profile and checkout 2026-10-17 23:35:35 +03:30
1ce33d9b74 refactor: optimze modal 2026-10-02 13:37:59 +03:30
800efeb784 feat: show the admin-managed announcement popup when the site opens
Fetch the live notice from the API so users see maintenance updates without a hardcoded toast.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 22:05:40 +03:30
9286a283c0 feat: full profile editing (email/password/mobile/card) + lighter background particles
- ModalEditProfile now lets users optionally change email, password,
  and card number, plus a two-step OTP flow to change their mobile
  number. All fields remain optional except when actively submitted.
- Particle background: dropped canvas shadowBlur and the warp effect
  on links (the most expensive per-frame draw calls), disabled
  detectRetina (was rendering at 2-3x resolution on high-DPI
  screens), capped fps at 30, and paused rendering when the tab is
  hidden or the canvas is out of viewport (pauseOnBlur /
  pauseOnOutsideViewport) — this was the main site-wide slowdown
  since it renders on every page via the root layout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 01:38:00 +03:30
bbf1fc8899 feat: render the connection-warning block from backend data per service
Replaces the hardcoded "⚠️ اتصال سرویس‌ها" text (shown for every product)
with data.has_connection_notice / data.connection_notice from the
config's own see() response, so it's opt-in per service instead of
always-on for the whole category.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 01:14:33 +03:30
e1a84105c7 feat: require reading the service description before purchase
For categories with has_extra_description, the buy button now stays
disabled until the user ticks "توضیحات سرویس را مطالعه کردم" — mirrors
the pattern used for the Shahkar/advanced-verification gates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 00:14:02 +03:30
897146ee12 feat: let users save their national code from the profile page
Wires up the previously-disabled "edit profile" button/modal to save
national_id via POST /profile/national-id, so it's no longer only
collectible through the inline field during a Shahkar-gated purchase.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 05:29:25 +03:30
14 changed files with 668 additions and 158 deletions

View File

@@ -1,6 +1,4 @@
# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS build
FROM docker.arvancloud.ir/node:22-alpine AS build
WORKDIR /app
@@ -16,10 +14,12 @@ ENV VITE_API_URL=${VITE_API_URL}
RUN npm run build
FROM nginxinc/nginx-unprivileged:1.28-alpine AS runtime
FROM docker.arvancloud.ir/nginxinc/nginx-unprivileged:1.28-alpine AS runtime
COPY --chown=nginx:nginx nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build --chown=nginx:nginx /app/dist /usr/share/nginx/html
COPY --from=build --chown=nginx:nginx /app/robots.txt /usr/share/nginx/html/robots.txt
COPY --from=build --chown=nginx:nginx /app/sitemap.xml /usr/share/nginx/html/sitemap.xml
USER nginx

8
robots.txt Normal file
View File

@@ -0,0 +1,8 @@
User-agent: *
Allow: /
Disallow: /profile
Disallow: /login
Disallow: /advanced-verification
Disallow: /noactive-payment
Sitemap: https://persain-game.com/sitemap.xml

27
sitemap.xml Normal file
View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://persain-game.com/</loc>
<lastmod>2026-11-02</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://persain-game.com/tutorial</loc>
<lastmod>2026-11-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://persain-game.com/rule</loc>
<lastmod>2026-11-02</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://persain-game.com/about</loc>
<lastmod>2026-11-02</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
</urlset>

View File

@@ -23,6 +23,7 @@ import { useSelector } from "react-redux";
import { RootState } from "./redux/store";
import Page404 from "./pages/404";
import RedirectToHome from "./utils/redirect-to-home ";
import SiteAnnouncementModal from "./components/siteAnnouncement/SiteAnnouncementModal";
// import LandingLayout from "./layout/LandingLayout";
// import Landing from "./pages/landing";
@@ -40,6 +41,7 @@ const App = () => {
}}
/>
<ProgressBar />
<SiteAnnouncementModal />
<Routes>
{/* <Route path="/" element={<LandingLayout />}>
<Route index element={<Landing />}></Route>

View File

@@ -14,6 +14,7 @@ export default function MuiModal({
submitOnClick,
disabled,
handleCloseCustom,
aboveSubmit,
children,
}: MuiModalProps) {
const [open, setOpen] = useState(false);
@@ -57,22 +58,27 @@ export default function MuiModal({
<motion.div className="w-full flex-1 flex flex-col justify-between text-center items-center overflow-y-auto">
{children}
</motion.div>
<motion.div
className={`w-full h-full flex ${
submitText ? "justify-end" : "justify-center"
} items-center gap-2 md:gap-3 text-lg md:text-xl`}
>
<Button onClick={handleCloseCustom || handleClose}> بستن </Button>
{submitText && (
<Button
extra={`${disabled && "!bg-slate-300"}`}
onClick={submitOnClick}
disabled={disabled}
>
{" "}
{submitText}{" "}
</Button>
<motion.div className="w-full h-full flex flex-col gap-2">
{aboveSubmit && (
<div className="w-full flex justify-center">{aboveSubmit}</div>
)}
<motion.div
className={`w-full h-full flex ${
submitText ? "justify-end" : "justify-center"
} items-center gap-2 md:gap-3 text-lg md:text-xl`}
>
<Button onClick={handleCloseCustom || handleClose}> بستن </Button>
{submitText && (
<Button
extra={`${disabled && "!bg-slate-300"}`}
onClick={submitOnClick}
disabled={disabled}
>
{" "}
{submitText}{" "}
</Button>
)}
</motion.div>
</motion.div>
</motion.div>
</Modal>

View File

@@ -1,7 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import Particles, { initParticlesEngine } from "@tsparticles/react";
import {
type Container,
type ISourceOptions,
MoveDirection,
OutMode,
@@ -26,10 +25,6 @@ export default function Particle() {
});
}, []);
const particlesLoaded = async (container?: Container): Promise<void> => {
console.log(container);
};
const options: ISourceOptions = useMemo(
() => ({
background: {
@@ -37,7 +32,9 @@ export default function Particle() {
value: "",
},
},
fpsLimit: 60,
fpsLimit: 30,
pauseOnBlur: true,
pauseOnOutsideViewport: true,
interactivity: {
events: {
onClick: {
@@ -69,20 +66,6 @@ export default function Particle() {
enable: true,
opacity: 0.8,
width: 1,
shadow: {
enable: true,
blur: 5,
color: "#71fbb2",
},
warp: {
enable: true,
speed: 2,
},
animation: {
enable: true,
speed: 0.5,
sync: true,
},
},
move: {
direction: MoveDirection.none,
@@ -110,7 +93,7 @@ export default function Particle() {
value: { min: 1, max: 5 },
},
},
detectRetina: true,
detectRetina: false,
}),
[]
);
@@ -121,11 +104,7 @@ export default function Particle() {
if (init) {
return (
<Particles
id="tsparticles"
particlesLoaded={particlesLoaded}
options={options}
/>
<Particles id="tsparticles" options={options} />
);
}

View File

@@ -0,0 +1,128 @@
import Modal from "@mui/material/Modal";
import { useQuery } from "@tanstack/react-query";
import { motion } from "framer-motion";
import { useEffect, useState } from "react";
import { FaTelegram } from "react-icons/fa6";
import { IoClose } from "react-icons/io5";
import { MdCampaign } from "react-icons/md";
import useAxios from "../../hooks/useAxios";
import Button from "../button";
type SiteAnnouncement = {
enabled: boolean;
title?: string;
body?: string;
support_url?: string;
support_label?: string;
version?: string;
};
const STORAGE_KEY = "site_announcement_dismissed_version";
const SiteAnnouncementModal = () => {
const axios = useAxios();
const [isOpen, setIsOpen] = useState(false);
const { data } = useQuery({
queryKey: ["site-announcement"],
queryFn: () => axios.get("/home/announcement"),
});
const announcement = data?.data?.result as SiteAnnouncement | undefined;
useEffect(() => {
if (!announcement?.enabled) {
setIsOpen(false);
return;
}
const dismissedVersion = sessionStorage.getItem(STORAGE_KEY);
if (dismissedVersion && dismissedVersion === String(announcement.version)) {
setIsOpen(false);
return;
}
setIsOpen(true);
}, [announcement]);
const handleClose = () => {
if (announcement?.version) {
sessionStorage.setItem(STORAGE_KEY, String(announcement.version));
}
setIsOpen(false);
};
if (!announcement?.enabled) {
return null;
}
return (
<Modal
open={isOpen}
onClose={handleClose}
aria-labelledby="site-announcement-title"
aria-describedby="site-announcement-body"
className="relative w-full h-full flex justify-center items-center p-4"
>
<motion.div
role="dialog"
aria-modal="true"
className="w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl border border-secondTheme/20 bg-[rgb(18,18,18,0.98)] p-5 md:p-6 shadow-2xl shadow-black/50 outline-none"
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.28 }}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 text-secondTheme">
<MdCampaign className="w-7 h-7 shrink-0" aria-hidden="true" />
<h2
id="site-announcement-title"
className="text-xl md:text-2xl font-bold text-white"
>
{announcement.title}
</h2>
</div>
<button
type="button"
onClick={handleClose}
aria-label="بستن اطلاعیه"
className="min-w-11 min-h-11 rounded-lg flex items-center justify-center text-gray-300 hover:text-white hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-secondTheme"
>
<IoClose className="w-7 h-7" />
</button>
</div>
<p
id="site-announcement-body"
className="mt-5 text-sm md:text-base leading-8 text-gray-200 whitespace-pre-line text-right"
>
{announcement.body}
</p>
<div className="mt-6 flex flex-col sm:flex-row gap-3">
{announcement.support_url && (
<a
href={announcement.support_url}
target="_blank"
rel="noopener noreferrer"
className="flex-1 min-h-11 px-4 py-2 rounded-md bg-[#229ED9] text-white hover:bg-[#1b8bc0] focus-visible:ring-2 focus-visible:ring-sky-300 flex items-center justify-center gap-2"
>
<FaTelegram className="w-5 h-5" aria-hidden="true" />
<span>
Support: {announcement.support_label || "@PersianPingSupport"}
</span>
</a>
)}
<Button
extra="!flex-1 !min-h-11 !bg-gray-800 !text-white hover:!bg-gray-700 focus-visible:ring-2 focus-visible:ring-secondTheme"
onClick={handleClose}
>
متوجه شدم
</Button>
</div>
</motion.div>
</Modal>
);
};
export default SiteAnnouncementModal;

View File

@@ -1,10 +1,12 @@
import { MdLogout } from "react-icons/md";
import { MdEdit, MdLogout } from "react-icons/md";
import Button from "../../../components/button";
import { useDispatch } from "react-redux";
import { setToken } from "../../../redux/slices/token";
import { AxiosError } from "axios";
import { LoaderIcon } from "react-hot-toast";
import { ProfileDetailsProps } from "../../../types/props";
import { useState } from "react";
import ModalEditProfile from "./components/ModalEditProfile";
const ProfileDetails = ({
data,
@@ -14,7 +16,13 @@ const ProfileDetails = ({
}: ProfileDetailsProps) => {
return (
<div className="relative z-[10] w-full min-h-32 max-md:min-h-20 flex flex-row-reverse max-[400px]:gap-3 justify-between flex-wrap-reverse glass items-center py-2 px-4">
<EditProfile />
<EditProfile
identityVerified={data?.result?.user_profile?.is_identity_verified}
nationalId={data?.result?.user_profile?.national_id}
email={data?.result?.user_profile?.email}
mobile={data?.result?.user_profile?.mobile}
cardNumber={data?.result?.user_profile?.card_number}
/>
<ProductDetails
enable_config={data?.result?.enable_config}
disable_config={data?.result?.disable_config}
@@ -28,18 +36,38 @@ const ProfileDetails = ({
export default ProfileDetails;
const EditProfile = () => {
// const [isOpen, setIsOpen] = useState(false);
const EditProfile = ({
identityVerified,
nationalId,
email,
mobile,
cardNumber,
}: {
identityVerified?: boolean;
nationalId?: string | null;
email?: string | null;
mobile?: string | null;
cardNumber?: string | null;
}) => {
const [isOpen, setIsOpen] = useState(false);
const dispatch = useDispatch();
return (
<div className="max-[400px]:w-full w-auto h-full flex justify-end items-center gap-2">
{/* <ModalEditProfile isOpen={isOpen} setIsOpen={setIsOpen} /> */}
{/* <Button
<ModalEditProfile
isOpen={isOpen}
setIsOpen={setIsOpen}
identityVerified={identityVerified}
nationalId={nationalId}
email={email}
mobile={mobile}
cardNumber={cardNumber}
/>
<Button
extra="!py-4 max-md:!py-2 max-md:px-2"
onClick={() => setIsOpen(true)}
>
<MdEdit className="scale-[1.4] max-md:scale-100" />
</Button> */}
</Button>
<Button
extra="!py-4 max-[400px]:w-full max-md:!py-2 max-md:px-2 !bg-rose-600 flex gap-1 justify-center items-center !text-white"
onClick={() => dispatch(setToken(""))}

View File

@@ -1,23 +1,329 @@
import { Dispatch, SetStateAction } from "react";
import { Dispatch, SetStateAction, useEffect, useState } from "react";
import MuiModal from "../../../../common/modal";
import Input from "../../../../components/input";
import Button from "../../../../components/button";
import Alert from "../../../../components/alert/Alert";
import useAxios from "../../../../hooks/useAxios";
import { useMutation } from "@tanstack/react-query";
import { AxiosError, AxiosResponse } from "axios";
import toast from "react-hot-toast";
import { queryClient } from "../../../../main";
const extractErrorMessage = (err: AxiosError): string => {
const data = err.response?.data;
if (data && typeof data === "object") {
if ("alert" in data && (data as { alert?: string }).alert) {
return (data as { alert: string }).alert;
}
if ("message" in data && (data as { message?: string }).message) {
return (data as { message: string }).message;
}
}
return "یک خطای غیرمنتظره رخ داد";
};
const ModalEditProfile = ({
isOpen,
setIsOpen,
nationalId,
identityVerified = false,
email,
mobile,
cardNumber,
}: {
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
nationalId?: string | null;
identityVerified?: boolean;
email?: string | null;
mobile?: string | null;
cardNumber?: string | null;
}) => {
const axios = useAxios();
const [nationalCode, setNationalCode] = useState(nationalId ?? "");
const [emailValue, setEmailValue] = useState(email ?? "");
const [password, setPassword] = useState("");
const [passwordConfirmation, setPasswordConfirmation] = useState("");
const [cardNumberValue, setCardNumberValue] = useState(cardNumber ?? "");
const [newMobile, setNewMobile] = useState("");
const [mobileStep, setMobileStep] = useState<"idle" | "code-sent">("idle");
const [mobileCode, setMobileCode] = useState("");
useEffect(() => {
if (!isOpen) return;
setNationalCode(nationalId ?? "");
setEmailValue(email ?? "");
setPassword("");
setPasswordConfirmation("");
setCardNumberValue(cardNumber ?? "");
setNewMobile("");
setMobileStep("idle");
setMobileCode("");
}, [isOpen, nationalId, email, cardNumber]);
const saveMutation = useMutation({
mutationKey: ["saveProfile"],
mutationFn: async () => {
const requests = [];
if (!identityVerified && nationalCode.trim() && nationalCode.trim() !== (nationalId ?? "")) {
requests.push(
axios.post("/profile/national-id", {
national_id: nationalCode.trim(),
})
);
}
const updatePayload: Record<string, string> = {};
if (emailValue.trim() !== (email ?? "")) {
updatePayload.email = emailValue.trim();
}
if (password) {
updatePayload.password = password;
updatePayload.password_confirmation = passwordConfirmation;
}
if (cardNumberValue.trim() !== (cardNumber ?? "")) {
updatePayload.card_number = cardNumberValue.trim();
}
if (Object.keys(updatePayload).length > 0) {
requests.push(axios.post("/profile/update", updatePayload));
}
if (requests.length === 0) {
throw new Error("هیچ تغییری برای ذخیره وجود ندارد");
}
return Promise.all(requests);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["profile"] });
},
});
const submitHandler = () => {
if (!identityVerified && nationalCode.trim() && nationalCode.trim().length !== 10) {
toast.error("کد ملی باید ۱۰ رقم باشد");
return;
}
if (password && password.length < 6) {
toast.error("رمز عبور باید حداقل ۶ کاراکتر باشد");
return;
}
if (password && password !== passwordConfirmation) {
toast.error("تکرار رمز عبور مطابقت ندارد");
return;
}
toast.promise(saveMutation.mutateAsync(), {
loading: "درحال ذخیره...",
success: () => {
setIsOpen(false);
return "اطلاعات با موفقیت ذخیره شد";
},
error: (err: AxiosError | Error) => {
if (err instanceof Error && !("response" in err)) {
return err.message;
}
return extractErrorMessage(err as AxiosError);
},
});
};
const requestMobileCodeMutation = useMutation({
mutationKey: ["requestMobileChange"],
mutationFn: (mobileNumber: string) => {
return axios.post("/profile/mobile/request-change", {
mobile: mobileNumber,
});
},
});
const confirmMobileCodeMutation = useMutation({
mutationKey: ["confirmMobileChange"],
mutationFn: (code: string) => {
return axios.post("/profile/mobile/confirm-change", { code });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["profile"] });
},
});
const requestMobileCodeHandler = () => {
if (newMobile.trim().length !== 11) {
toast.error("شماره موبایل باید ۱۱ رقم باشد");
return;
}
toast.promise(requestMobileCodeMutation.mutateAsync(newMobile.trim()), {
loading: "درحال ارسال کد...",
success: (res: AxiosResponse) => {
setMobileStep("code-sent");
return `${res.data.alert}`;
},
error: (err: AxiosError) => extractErrorMessage(err),
});
};
const confirmMobileCodeHandler = () => {
if (mobileCode.trim().length !== 4) {
toast.error("کد تایید باید ۴ رقم باشد");
return;
}
toast.promise(confirmMobileCodeMutation.mutateAsync(mobileCode.trim()), {
loading: "درحال تایید...",
success: (res: AxiosResponse) => {
setMobileStep("idle");
setNewMobile("");
setMobileCode("");
return `${res.data.alert}`;
},
error: (err: AxiosError) => extractErrorMessage(err),
});
};
return (
<MuiModal
isOpenModal={isOpen}
setIsOpenModal={setIsOpen}
submitText="تایید"
submitText="ذخیره"
submitOnClick={submitHandler}
title="ویرایش پروفایل"
>
<div className="relative w-full h-full px-2 md:px-4">
<Input type="text" label="ویرایش نام :" labelStyle="!text-start" extra="!w-full" />
<div className="relative w-full h-full px-2 md:px-4 flex flex-col gap-4">
<div className="w-full flex flex-col gap-2">
<Input
type="text"
label="شماره موبایل فعلی :"
labelStyle="!text-start"
extra="!w-full opacity-60"
value={mobile ?? ""}
disabled
/>
{identityVerified ? (
<Alert type="info" extra="!mb-0 text-start">
شماره موبایل و کد ملی شما تأیید شده‌اند و قابل تغییر نیستند.
</Alert>
) : mobileStep === "idle" ? (
<div className="w-full flex flex-col gap-2">
<Input
type="text"
label="تغییر شماره موبایل :"
labelStyle="!text-start"
extra="!w-full"
placeholder="شماره موبایل جدید"
value={newMobile}
onChange={(e) =>
setNewMobile(e.target.value.replace(/[^0-9]/g, ""))
}
/>
{newMobile.trim().length === 11 && (
<Button extra="!py-2" onClick={requestMobileCodeHandler}>
ارسال کد تایید
</Button>
)}
</div>
) : (
<div className="w-full flex flex-col gap-2">
<Alert type="warning" extra="!mb-0 text-start">
کد تایید به شماره {newMobile} پیامک شد.
</Alert>
<Input
type="text"
label="کد تایید :"
labelStyle="!text-start"
extra="!w-full"
placeholder="کد ۴ رقمی"
value={mobileCode}
onChange={(e) =>
setMobileCode(e.target.value.replace(/[^0-9]/g, ""))
}
/>
<div className="w-full flex gap-2">
<Button extra="!py-2" onClick={confirmMobileCodeHandler}>
تایید و تغییر شماره
</Button>
<Button
extra="!py-2 !bg-gray-600 !text-white"
onClick={() => {
setMobileStep("idle");
setMobileCode("");
}}
>
انصراف
</Button>
</div>
</div>
)}
</div>
<Input
type="email"
label="ایمیل :"
labelStyle="!text-start"
extra="!w-full"
placeholder="ایمیل (اختیاری)"
value={emailValue}
onChange={(e) => setEmailValue(e.target.value)}
/>
<div className="w-full flex flex-col gap-2">
<Input
type="password"
label="رمز عبور جدید :"
labelStyle="!text-start"
extra="!w-full"
placeholder="در صورت تمایل به تغییر رمز پر کنید"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{password && (
<Input
type="password"
label="تکرار رمز عبور جدید :"
labelStyle="!text-start"
extra="!w-full"
placeholder="تکرار رمز عبور جدید"
value={passwordConfirmation}
onChange={(e) => setPasswordConfirmation(e.target.value)}
/>
)}
</div>
<Input
type="text"
label="شماره کارت :"
labelStyle="!text-start"
extra="!w-full"
placeholder="شماره کارت (اختیاری)"
value={cardNumberValue}
onChange={(e) =>
setCardNumberValue(e.target.value.replace(/[^0-9]/g, ""))
}
/>
{!identityVerified && (
<Alert type="warning" extra="!mb-0 text-start">
کد ملی برای احراز هویت شاهکار در خریدهای با مبلغ بالا لازم است.
</Alert>
)}
<Input
type="text"
label="کد ملی :"
labelStyle="!text-start"
extra="!w-full"
placeholder="کد ملی ۱۰ رقمی"
value={nationalCode}
disabled={identityVerified}
onChange={(e) =>
setNationalCode(e.target.value.replace(/[^0-9]/g, ""))
}
/>
</div>
</MuiModal>
);

View File

@@ -1,86 +1,63 @@
import { useQuery } from "@tanstack/react-query";
import Warning from "../../../assets/gifs/warning.gif";
import useAxios from "../../../hooks/useAxios";
import Alert from "../../../components/alert/Alert";
import Button from "../../../components/button";
type TermsContent = {
title: string;
introduction: string;
notice: string;
preamble: string;
clauses: string[];
};
const Rules = () => {
const axios = useAxios();
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["siteTerms"],
queryFn: async () => {
const response = await axios.get<{ result: TermsContent }>("/home/terms");
return response.data.result;
},
staleTime: 0,
});
if (isLoading) {
return <p role="status" className="p-4 text-center">در حال دریافت قوانین و مقررات...</p>;
}
if (isError || !data) {
return (
<div className="p-4 flex flex-col items-center gap-3">
<Alert type="warning">دریافت قوانین و مقررات انجام نشد. دوباره تلاش کنید.</Alert>
<Button onClick={() => refetch()}>تلاش دوباره</Button>
</div>
);
}
return (
<div className="relative w-full h-full flex flex-col px-4 py-4 text-start">
<h1 className="font-bold text-xl md:text-2xl text-center">
قوانین و مقررات
</h1>
<p className="font-normal text-sm mt-2 md:text-lg text-start">
لطفا با دقت زیاد متن توضیح زیر را بخوانید :<br />
قوانین پرشین پینگ لطفا قبل از خریداری به نکاتی که در زیر گفته شده دقت
کنید و با آگاهی به این شرایط خرید بفرمایید:
</p>
<p className="font-bold text-sm mt-2 md:text-lg text-yellow-500 text-start">
این سرویس تابع قوانین ایران می‌باشد
</p>
<p className="font-normal text-sm mt-2 md:text-lg text-start">
با تهیه و استفاده از اکانت پرشین پینگ می‌پذیرید:
</p>
<ListRules />
<div className="relative w-full h-full flex flex-col px-4 py-4 text-start" dir="rtl">
<h1 className="font-bold text-xl md:text-2xl text-center">{data.title}</h1>
{data.introduction && (
<p className="font-normal text-sm mt-2 md:text-lg text-start whitespace-pre-line">{data.introduction}</p>
)}
{data.notice && (
<p className="font-bold text-sm mt-2 md:text-lg text-yellow-500 text-start whitespace-pre-line">{data.notice}</p>
)}
{data.preamble && (
<p className="font-normal text-sm mt-2 md:text-lg text-start whitespace-pre-line">{data.preamble}</p>
)}
<ul className="w-full h-full mt-2">
{data.clauses.map((clause, index) => (
<li key={index} className="flex flex-row mt-2 gap-2 w-full bg-gray-800 p-2 rounded-md items-center">
<img src={Warning} alt="" className="w-[2rem] shrink-0" />
<p className="font-normal max-sm:text-xs max-md:text-sm md:text-lg text-start whitespace-pre-line">{clause}</p>
</li>
))}
</ul>
</div>
);
};
export default Rules;
const ListRules = () => {
return (
<div className="w-full h-full mt-2">
<ul>
<li className="flex flex-row mt-2 gap-2 w-full bg-gray-800 p-2 rounded-md items-center">
<img src={Warning} alt="warning" className=" w-[2rem]" />
<p className="font-normal max-sm:text-xs max-md:text-sm md:text-lg text-start">
هرگونه استفاده غیر مجاز و غیر قانونی به عهده شخص استفاده‌کننده
می‌باشد.
</p>
</li>
<li className="flex flex-row mt-2 gap-2 w-full bg-gray-800 p-2 rounded-md items-center">
<img src={Warning} alt="warning" className="w-[2rem]" />
<p className="font-normal max-sm:text-xs max-md:text-sm md:text-lg text-start">
در صورتی که سرویس خریداری شده یک کاربره باشد و اگر سرویس خود را در
اختیار شخص دیگری قرار دهید سرویس شما به صورت خودکار مسدود خواهد شد.{" "}
</p>
</li>
<li className="flex flex-row mt-2 gap-2 w-full bg-gray-800 p-2 rounded-md items-center">
<img src={Warning} alt="warning" className="w-[2rem]" />
<p className="font-normal max-sm:text-xs max-md:text-sm md:text-lg text-start">
در صورتی که مشکلی برای سرویس‌ها به وجود بیاید تمام سعی خود را
می‌کنیم تا هر چه سریع‌تر مشکل را برطرف کنیم. بازه زمانی بین 10 دقیقه
الی حداکثر 24 ساعت می‌باشد.{" "}
</p>
</li>
<li className="flex flex-row mt-2 gap-2 w-full bg-gray-800 p-2 rounded-md items-center">
<img src={Warning} alt="warning" className="w-[2rem]" />
<p className="font-normal max-sm:text-xs max-md:text-sm md:text-lg text-start">
تمامی سرویس‌های ما مخصوص بازی می‌باشد و برای کاهش پینگ در بازی‌های
آنلاین و عبور از تحریم بازی‌ها استفاده می‌شود و هرگونه استفاده غیر
مجاز از سرویس‌ها به عنوان V.p.n و استفاده‌های دیگر ممنوع می‌باشد و
در صورت مشاهده سرویس شما مسدود می‌گردد و هزینه‌ای هم برگشت داده
نخواهد شد.
</p>
</li>
<li className="flex flex-row mt-2 gap-2 w-full bg-gray-800 p-2 rounded-md items-center">
<img src={Warning} alt="warning" className="w-[2rem]" />
<p className="font-normal max-sm:text-xs max-md:text-sm md:text-lg text-start">
به‌دلیل برخی پروتکل‌های امنیتی و به‌منظور جلوگیری از سوءاستفاده،
برای خریدهای با مبلغ بیش از ۲۰ میلیون تومان، احراز هویت پیشرفته
انجام می‌شود.
</p>
</li>
<li className="flex flex-row mt-2 gap-2 w-full bg-gray-800 p-2 rounded-md items-center">
<img src={Warning} alt="warning" className="w-[2rem]" />
<p className="font-normal max-sm:text-xs max-md:text-sm md:text-lg text-start">
⚠️ اتصال سرویس‌ها
<br />
با توجه به شرایط فعلی، هیچ تضمینی برای اتصال دائمی سرویس‌ها وجود ندارد. ممکن است اتصال مجدداً قطع شود یا حتی در برخی مواقع امکان برقراری آن فراهم نشود. در حال حاضر، وضعیت کاملاً مشخص و قابل پیش‌بینی نیست.
<br />
با این حال، ما مانند همیشه تمام تلاش خود را به کار می‌گیریم. خوشبختانه تا این لحظه، تلاش‌هایمان بی‌نتیجه نبوده و هر بار پس از بروز مشکل، توانسته‌ایم اتصال را مجدداً برقرار کرده و پایداری سرویس‌ها را حفظ کنیم.
<br />
از همراهی و صبوری شما سپاسگزاریم.
</p>
</li>
</ul>
</div>
);
};

View File

@@ -47,6 +47,8 @@ const ModalBuy = ({ isOpen, setIsOpen, configId }: ModalBuyProps) => {
const [isInfoOpen, setIsInfoOpen] = useState(false);
const [needsShahkarVerification, setNeedsShahkarVerification] = useState(false);
const [nationalCode, setNationalCode] = useState("");
const identityVerified = Boolean(data?.data?.result?.is_identity_verified);
const [hasReadDescription, setHasReadDescription] = useState(false);
const payablePrice = toNumber(
price === 0 ? data?.data?.result?.price || 0 : price
);
@@ -60,6 +62,10 @@ const ModalBuy = ({ isOpen, setIsOpen, configId }: ModalBuyProps) => {
data?.data?.result?.has_extra_description
);
const extraDescriptionSections = data?.data?.result?.extra_description ?? [];
const hasConnectionNotice = Boolean(
data?.data?.result?.has_connection_notice
);
const connectionNotice = data?.data?.result?.connection_notice ?? "";
const advancedVerificationFromApi =
data?.data?.result?.requires_advanced_verification;
const hasAdvancedVerificationApiFlag =
@@ -103,10 +109,14 @@ const ModalBuy = ({ isOpen, setIsOpen, configId }: ModalBuyProps) => {
setPrice(0);
setNeedsShahkarVerification(false);
setNationalCode("");
setHasReadDescription(false);
}
}, [isOpen, setFieldValue]);
const mutation = useMutation({
mutationKey: ["buyConfig"],
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["profile"] });
},
mutationFn: (values: BuyConfigDataProps) => {
return axios.post(`home/config/${configId}/buy`, values);
},
@@ -189,12 +199,17 @@ const ModalBuy = ({ isOpen, setIsOpen, configId }: ModalBuyProps) => {
};
const submitHandler = () => {
if (hasExtraDescription && !hasReadDescription) {
toast.error("لطفا ابتدا توضیحات سرویس را مطالعه و تایید کنید");
return;
}
if (requiresAdvancedVerification) {
goToAdvancedVerification();
return;
}
if (needsShahkarVerification && nationalCode.trim().length !== 10) {
if (needsShahkarVerification && !identityVerified && nationalCode.trim().length !== 10) {
toast.error("لطفا کد ملی ۱۰ رقمی را به‌درستی وارد کنید");
return;
}
@@ -202,7 +217,8 @@ const ModalBuy = ({ isOpen, setIsOpen, configId }: ModalBuyProps) => {
const payload = {
...formik.values,
status: "buy",
...(needsShahkarVerification ? { nationalCode: nationalCode.trim() } : {}),
...(hasExtraDescription ? { hasReadDescription: true } : {}),
...(needsShahkarVerification && !identityVerified ? { nationalCode: nationalCode.trim() } : {}),
};
toast.promise(mutation.mutateAsync(payload), {
@@ -227,6 +243,9 @@ const ModalBuy = ({ isOpen, setIsOpen, configId }: ModalBuyProps) => {
}
const responseData = err.response?.data;
if (responseData && typeof responseData === "object" && "message" in responseData && typeof responseData.message === "string") {
errorMessage = responseData.message;
}
const isResponseObject =
responseData !== null && typeof responseData === "object";
const requiresAdvancedVerificationFromApi =
@@ -244,13 +263,14 @@ const ModalBuy = ({ isOpen, setIsOpen, configId }: ModalBuyProps) => {
(err.response?.status === 422 &&
errorMessage.includes("احراز هویت پیشرفته"));
const requiresShahkarVerificationFromApi =
isResponseObject &&
"requires_shahkar_verification" in responseData &&
Boolean(
(responseData as { requires_shahkar_verification?: boolean })
.requires_shahkar_verification
);
const shahkarError = responseData as {
requires_shahkar_verification?: boolean;
result?: { requires_shahkar_verification?: boolean };
} | undefined;
const requiresShahkarVerificationFromApi = Boolean(
shahkarError?.requires_shahkar_verification ||
shahkarError?.result?.requires_shahkar_verification
);
if (requiresShahkarVerificationFromApi) {
setNeedsShahkarVerification(true);
@@ -280,10 +300,7 @@ const ModalBuy = ({ isOpen, setIsOpen, configId }: ModalBuyProps) => {
setIsOpenModal={setIsOpen}
submitText={data?.data?.result?.max_config === 0 ? undefined : "خرید"}
submitOnClick={submitHandler}
disabled={
isLoading ||
isFetching
}
disabled={isLoading || isFetching}
nameService={
isLoading || isFetching
? "درحال بارگذاری..."
@@ -291,6 +308,19 @@ const ModalBuy = ({ isOpen, setIsOpen, configId }: ModalBuyProps) => {
? data?.data.result.title
: "خطا رخ داد"
}
aboveSubmit={
hasExtraDescription ? (
<label className="w-full flex flex-row-reverse items-center justify-center gap-2 text-xs md:text-sm text-gray-300 cursor-pointer select-none">
<input
type="checkbox"
checked={hasReadDescription}
onChange={(e) => setHasReadDescription(e.target.checked)}
className="w-4 h-4 accent-secondTheme"
/>
توضیحات سرویس را مطالعه کردم
</label>
) : undefined
}
>
<div className="relative w-full h-full px-2 md:px-4">
{error && (
@@ -342,10 +372,12 @@ const ModalBuy = ({ isOpen, setIsOpen, configId }: ModalBuyProps) => {
کاربر گرامی لطفا از خاموش بودن vpn خود اطمینان حاصل فرمایید تا در
درگاه پرداخت مشکلی نداشته باشید{" "}
</Alert>
<div className="w-full bg-gray-800 rounded-md p-2 text-xs text-gray-400 leading-6 text-right">
<p className="font-bold text-yellow-400 text-xs mb-1">⚠️ اتصال سرویس‌ها</p>
<p>با توجه به شرایط فعلی، هیچ تضمینی برای اتصال دائمی سرویس‌ها وجود ندارد. ممکن است اتصال مجدداً قطع شود یا حتی در برخی مواقع امکان برقراری آن فراهم نشود. با این حال، تمام تلاش خود را می‌کنیم تا هر چه سریع‌تر مشکل را برطرف کنیم. از همراهی و صبوری شما سپاسگزاریم.</p>
</div>
{hasConnectionNotice && (
<div className="w-full bg-gray-800 rounded-md p-2 text-xs text-gray-400 leading-6 text-right">
<p className="font-bold text-yellow-400 text-xs mb-1">⚠️ اتصال سرویس</p>
<p>{connectionNotice}</p>
</div>
)}
<h1 className="font-bold">
مبلغ قابل پرداخت :{" "}
<span className="px-[2px] rounded-md bg-secondTheme text-gray-950">
@@ -358,7 +390,7 @@ const ModalBuy = ({ isOpen, setIsOpen, configId }: ModalBuyProps) => {
{advancedVerificationMessage}
</Alert>
)}
{needsShahkarVerification && (
{needsShahkarVerification && !identityVerified && (
<div className="w-full flex flex-col gap-2">
<Alert type="warning" extra="!mb-0 text-start">
برای خریدهای با مبلغ بالا، احراز هویت شاهکار (تطبیق شماره
@@ -471,7 +503,7 @@ const ModalBuy = ({ isOpen, setIsOpen, configId }: ModalBuyProps) => {
)}
{data?.data?.result?.max_config !== 0 && !isLoading && !isFetching && (
<p className="w-full text-center text-xs text-gray-500 mt-1">
با خرید، <span className="text-gray-400">قوانین و مقررات</span> را
با خرید، <a href="/rule" target="_blank" rel="noopener noreferrer" className="text-gray-400 underline">قوانین و مقررات</a> را
می‌پذیرید.
</p>
)}

View File

@@ -1,7 +1,7 @@
import { Dispatch, SetStateAction } from "react";
import MuiModal from "../../../../../common/modal";
interface DescriptionSection {
export interface DescriptionSection {
emoji?: string;
title?: string;
items?: string[];
@@ -41,7 +41,6 @@ const ModalDnsServiceInfo = ({
isOpenModal={isOpen}
setIsOpenModal={setIsOpen}
title="توضیحات سرویس"
nameService="توضیحات سرویس"
>
<div className="w-full h-full flex flex-col gap-3 max-sm:text-xs max-md:text-sm text-base">
{(sections ?? []).map((section, sectionIndex) => (

View File

@@ -116,6 +116,7 @@ export interface MuiModalProps {
submitOnClick?: () => void;
disabled?: boolean;
handleCloseCustom ?: () => void;
aboveSubmit?: React.ReactNode;
children: React.ReactNode;
}
@@ -217,6 +218,10 @@ type UserProfile = {
name: string;
email: string;
mobile: string;
national_id?: string | null;
is_identity_verified?: boolean;
shahkar_verified_at?: string | null;
card_number?: string | null;
};
// تعریف تایپ پیکربندی‌ها

View File

@@ -1,7 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { copyFileSync } from "node:fs";
import { resolve } from "node:path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
function copySeoFiles() {
return {
name: "copy-seo-files",
closeBundle() {
const root = resolve(__dirname);
const outDir = resolve(root, "dist");
copyFileSync(resolve(root, "robots.txt"), resolve(outDir, "robots.txt"));
copyFileSync(resolve(root, "sitemap.xml"), resolve(outDir, "sitemap.xml"));
},
};
}
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [react(), copySeoFiles()],
});