diff --git a/src/App.tsx b/src/App.tsx index 5b484ca..909a8c2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -43,6 +43,7 @@ const PurchaseSuccess = lazy(() => import('./pages/PurchaseSuccess')); const AutoLogin = lazy(() => import('./pages/AutoLogin')); const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect')); const TopUpAmount = lazy(() => import('./pages/TopUpAmount')); +const TopUpResult = lazy(() => import('./pages/TopUpResult')); const ConnectedAccounts = lazy(() => import('./pages/ConnectedAccounts')); const LinkTelegramCallback = lazy(() => import('./pages/LinkTelegramCallback')); const MergeAccounts = lazy(() => import('./pages/MergeAccounts')); @@ -112,7 +113,13 @@ const AdminLandings = lazy(() => import('./pages/AdminLandings')); const AdminLandingEditor = lazy(() => import('./pages/AdminLandingEditor')); const AdminLandingStats = lazy(() => import('./pages/AdminLandingStats')); -function ProtectedRoute({ children }: { children: React.ReactNode }) { +function ProtectedRoute({ + children, + withLayout = true, +}: { + children: React.ReactNode; + withLayout?: boolean; +}) { const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const isLoading = useAuthStore((state) => state.isLoading); const location = useLocation(); @@ -122,12 +129,11 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { } if (!isAuthenticated) { - // Сохраняем текущий URL для возврата после авторизации saveReturnUrl(); return ; } - return {children}; + return withLayout ? {children} : <>{children}; } function AdminRoute({ children }: { children: React.ReactNode }) { @@ -141,7 +147,6 @@ function AdminRoute({ children }: { children: React.ReactNode }) { } if (!isAuthenticated) { - // Сохраняем текущий URL для возврата после авторизации saveReturnUrl(); return ; } @@ -283,6 +288,18 @@ function App() { } /> + + + + + + + + } + /> => { const response = await apiClient.get( - `/cabinet/balance/pending-payments/${method}/${paymentId}`, + `/cabinet/balance/pending-payments/${encodeURIComponent(method)}/${encodeURIComponent(paymentId)}`, ); return response.data; }, @@ -117,7 +117,7 @@ export const balanceApi = { // Manually check payment status checkPaymentStatus: async (method: string, paymentId: number): Promise => { const response = await apiClient.post( - `/cabinet/balance/pending-payments/${method}/${paymentId}/check`, + `/cabinet/balance/pending-payments/${encodeURIComponent(method)}/${encodeURIComponent(paymentId)}/check`, ); return response.data; }, diff --git a/src/components/ui/AnimatedCheckmark.tsx b/src/components/ui/AnimatedCheckmark.tsx new file mode 100644 index 0000000..461d59d --- /dev/null +++ b/src/components/ui/AnimatedCheckmark.tsx @@ -0,0 +1,37 @@ +import { motion } from 'framer-motion'; +import { cn } from '@/lib/utils'; + +export function AnimatedCheckmark({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/src/components/ui/AnimatedCrossmark.tsx b/src/components/ui/AnimatedCrossmark.tsx new file mode 100644 index 0000000..895c7be --- /dev/null +++ b/src/components/ui/AnimatedCrossmark.tsx @@ -0,0 +1,37 @@ +import { motion } from 'framer-motion'; +import { cn } from '@/lib/utils'; + +export function AnimatedCrossmark({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/src/components/ui/Spinner.tsx b/src/components/ui/Spinner.tsx new file mode 100644 index 0000000..607c9a1 --- /dev/null +++ b/src/components/ui/Spinner.tsx @@ -0,0 +1,17 @@ +import { useTranslation } from 'react-i18next'; +import { cn } from '@/lib/utils'; + +export function Spinner({ className }: { className?: string }) { + const { t } = useTranslation(); + + return ( +
+ ); +} diff --git a/src/locales/en.json b/src/locales/en.json index 8a12d70..094646a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -687,6 +687,19 @@ "paymentSuccess": { "title": "Payment Successful", "message": "Your balance has been topped up successfully. The funds are now available." + }, + "topUpResult": { + "awaitingPayment": "Awaiting Payment", + "awaitingPaymentDesc": "We are waiting for your payment confirmation. This may take a few minutes.", + "topUpAmount": "Top-up amount", + "success": "Balance Topped Up!", + "successDesc": "Your balance has been topped up successfully. The funds are now available.", + "failed": "Payment Failed", + "failedDesc": "Unfortunately, the payment was not completed. Please try again or choose a different payment method.", + "timeout": "Taking Longer Than Expected", + "timeoutDesc": "Payment processing is taking longer than usual. You can try checking the status again.", + "goToBalance": "Go to Balance", + "tryAgain": "Try Again" } }, "referral": { diff --git a/src/locales/fa.json b/src/locales/fa.json index d19a4c3..93f9501 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -532,6 +532,19 @@ "paymentSuccess": { "title": "پرداخت موفق", "message": "موجودی شما با موفقیت شارژ شد. وجوه اکنون در دسترس است." + }, + "topUpResult": { + "awaitingPayment": "در انتظار پرداخت", + "awaitingPaymentDesc": "ما منتظر تأیید پرداخت شما هستیم. این ممکن است چند دقیقه طول بکشد.", + "topUpAmount": "مبلغ شارژ", + "success": "شارژ موفق!", + "successDesc": "موجودی شما با موفقیت شارژ شد. وجوه اکنون در دسترس است.", + "failed": "پرداخت ناموفق", + "failedDesc": "متأسفانه پرداخت تکمیل نشد. لطفاً دوباره تلاش کنید یا روش پرداخت دیگری انتخاب کنید.", + "timeout": "بیشتر از حد معمول طول کشید", + "timeoutDesc": "پردازش پرداخت بیشتر از حد معمول طول می‌کشد. می‌توانید دوباره وضعیت را بررسی کنید.", + "goToBalance": "مشاهده موجودی", + "tryAgain": "تلاش مجدد" } }, "referral": { diff --git a/src/locales/ru.json b/src/locales/ru.json index 5d78d4f..9fc892c 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -715,6 +715,19 @@ "paymentSuccess": { "title": "Оплата прошла успешно", "message": "Ваш баланс успешно пополнен. Средства уже доступны." + }, + "topUpResult": { + "awaitingPayment": "Ожидание оплаты", + "awaitingPaymentDesc": "Мы ожидаем подтверждение вашего платежа. Это может занять несколько минут.", + "topUpAmount": "Сумма пополнения", + "success": "Баланс пополнен!", + "successDesc": "Ваш баланс успешно пополнен. Средства уже доступны.", + "failed": "Оплата не прошла", + "failedDesc": "К сожалению, платёж не был завершён. Попробуйте ещё раз или выберите другой способ оплаты.", + "timeout": "Дольше, чем обычно", + "timeoutDesc": "Обработка платежа занимает больше времени. Вы можете проверить статус ещё раз.", + "goToBalance": "Перейти к балансу", + "tryAgain": "Попробовать снова" } }, "referral": { diff --git a/src/locales/zh.json b/src/locales/zh.json index 40d03f5..008aabf 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -532,6 +532,19 @@ "paymentSuccess": { "title": "支付成功", "message": "您的余额已成功充值,资金现已可用。" + }, + "topUpResult": { + "awaitingPayment": "等待付款", + "awaitingPaymentDesc": "我们正在等待您的付款确认。这可能需要几分钟。", + "topUpAmount": "充值金额", + "success": "充值成功!", + "successDesc": "您的余额已成功充值,资金现已可用。", + "failed": "付款失败", + "failedDesc": "很遗憾,付款未完成。请重试或选择其他付款方式。", + "timeout": "处理时间较长", + "timeoutDesc": "付款处理时间比平时长。您可以再次检查状态。", + "goToBalance": "查看余额", + "tryAgain": "重试" } }, "referral": { diff --git a/src/pages/Balance.tsx b/src/pages/Balance.tsx index f591ccc..725d9b9 100644 --- a/src/pages/Balance.tsx +++ b/src/pages/Balance.tsx @@ -8,7 +8,6 @@ import { useAuthStore } from '../store/auth'; import { balanceApi } from '../api/balance'; import { useCurrency } from '../hooks/useCurrency'; import { API } from '../config/constants'; -import { useToast } from '../components/Toast'; import type { PaginatedResponse, Transaction } from '../types'; import { Card } from '@/components/data-display/Card'; @@ -45,7 +44,6 @@ export default function Balance() { const { formatAmount, currencySymbol } = useCurrency(); const [searchParams] = useSearchParams(); const navigate = useNavigate(); - const { showToast } = useToast(); const paymentHandledRef = useRef(false); // Fetch balance from API @@ -72,25 +70,17 @@ export default function Balance() { paymentStatus === 'completed' || searchParams.get('success') === 'true'; + const isFailed = + paymentStatus === 'failed' || paymentStatus === 'error' || paymentStatus === 'canceled'; + if (isSuccess) { paymentHandledRef.current = true; - - refetchBalance(); - refreshUser(); - queryClient.invalidateQueries({ queryKey: ['transactions'] }); - queryClient.invalidateQueries({ queryKey: ['subscription'] }); - queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); - - showToast({ - type: 'success', - title: t('balance.paymentSuccess.title'), - message: t('balance.paymentSuccess.message'), - duration: 6000, - }); - - navigate('/balance', { replace: true }); + navigate('/balance/top-up/result?status=success', { replace: true }); + } else if (isFailed) { + paymentHandledRef.current = true; + navigate('/balance/top-up/result?status=failed', { replace: true }); } - }, [searchParams, navigate, refetchBalance, refreshUser, queryClient, showToast, t]); + }, [searchParams, navigate]); const [promocode, setPromocode] = useState(''); const [promocodeLoading, setPromocodeLoading] = useState(false); diff --git a/src/pages/PurchaseSuccess.tsx b/src/pages/PurchaseSuccess.tsx index 94ac3f5..9474b12 100644 --- a/src/pages/PurchaseSuccess.tsx +++ b/src/pages/PurchaseSuccess.tsx @@ -8,6 +8,9 @@ import { landingApi } from '../api/landings'; import { authApi } from '../api/auth'; import { useAuthStore } from '../store/auth'; import { copyToClipboard } from '../utils/clipboard'; +import { Spinner } from '@/components/ui/Spinner'; +import { AnimatedCheckmark } from '@/components/ui/AnimatedCheckmark'; +import { AnimatedCrossmark } from '@/components/ui/AnimatedCrossmark'; import { cn } from '../lib/utils'; const MAX_POLL_MS = 10 * 60 * 1000; // 10 minutes @@ -16,17 +19,6 @@ const MAX_POLL_MS = 10 * 60 * 1000; // 10 minutes // Sub-components // ============================================================ -function Spinner({ className }: { className?: string }) { - return ( -
- ); -} - function PendingState() { const { t } = useTranslation(); @@ -135,33 +127,7 @@ function CabinetCredentialsState({ animate={{ opacity: 1, scale: 1 }} className="flex flex-col items-center gap-6 text-center" > - {/* Animated checkmark */} - - - - - + {/* Title */}
@@ -267,33 +233,7 @@ function SuccessState({ animate={{ opacity: 1, scale: 1 }} className="flex flex-col items-center gap-6 text-center" > - {/* Animated checkmark */} - - - - - + {/* Title */}
@@ -551,33 +491,7 @@ function GiftPendingActivationState({ animate={{ opacity: 1, scale: 1 }} className="flex flex-col items-center gap-6 text-center" > - {/* Animated checkmark */} - - - - - +

{t('landing.giftSentSuccess')}

@@ -631,17 +545,7 @@ function FailedState() { animate={{ opacity: 1, scale: 1 }} className="flex flex-col items-center gap-6 text-center" > -
- - - -
+

{t('landing.purchaseFailed')}

{t('landing.purchaseFailedDesc')}

@@ -785,7 +689,11 @@ export default function PurchaseSuccess() { return (
-
+
{isError ? ( ) : isEmailSelfPurchase ? ( diff --git a/src/pages/TopUpAmount.tsx b/src/pages/TopUpAmount.tsx index 3032224..4f3ac73 100644 --- a/src/pages/TopUpAmount.tsx +++ b/src/pages/TopUpAmount.tsx @@ -12,6 +12,7 @@ import { useHaptic, usePlatform } from '@/platform'; import { staggerContainer, staggerItem } from '@/components/motion/transitions'; import type { PaymentMethod } from '../types'; import BentoCard from '../components/ui/BentoCard'; +import { saveTopUpPendingInfo } from '../utils/topUpStorage'; // Icons const StarIcon = () => ( @@ -202,6 +203,20 @@ export default function TopUpAmount() { const redirectUrl = data.payment_url || data.invoice_url; if (redirectUrl) { setPaymentUrl(redirectUrl); + + // Save payment info for the result page + if (method && data.payment_id) { + const methodKey = method.id.toLowerCase().replace(/-/g, '_'); + const displayName = + t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) || method.name; + saveTopUpPendingInfo({ + amount_kopeks: data.amount_kopeks, + method_id: method.id, + method_name: displayName, + payment_id: data.payment_id, + created_at: Date.now(), + }); + } } }, onError: (err: unknown) => { @@ -296,8 +311,8 @@ export default function TopUpAmount() { await navigator.clipboard.writeText(paymentUrl); setCopied(true); setTimeout(() => setCopied(false), 2000); - } catch (e) { - console.warn('Failed to copy:', e); + } catch { + // Clipboard write failed silently } }; diff --git a/src/pages/TopUpResult.tsx b/src/pages/TopUpResult.tsx new file mode 100644 index 0000000..690f141 --- /dev/null +++ b/src/pages/TopUpResult.tsx @@ -0,0 +1,342 @@ +import { useState, useCallback, useRef, useEffect } from 'react'; +import { useNavigate, useSearchParams } from 'react-router'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { motion } from 'framer-motion'; + +import { balanceApi } from '../api/balance'; +import { useAuthStore } from '../store/auth'; +import { useCurrency } from '../hooks/useCurrency'; +import { useHaptic } from '@/platform'; +import { Spinner } from '@/components/ui/Spinner'; +import { AnimatedCheckmark } from '@/components/ui/AnimatedCheckmark'; +import { AnimatedCrossmark } from '@/components/ui/AnimatedCrossmark'; +import { loadTopUpPendingInfo, clearTopUpPendingInfo } from '../utils/topUpStorage'; + +// ── Constants ──────────────────────────────────────────────── +const MAX_POLL_MS = 10 * 60 * 1000; // 10 minutes +const POLL_INTERVAL_MS = 3_000; + +// ── Sub-components ─────────────────────────────────────────── + +function AmountDisplay({ amountKopeks, label }: { amountKopeks: number; label: string }) { + const { formatAmount, currencySymbol } = useCurrency(); + const amountRubles = amountKopeks / 100; + + return ( +
+

{label}

+

+ {formatAmount(amountRubles)} {currencySymbol} +

+
+ ); +} + +function PendingState({ amountKopeks }: { amountKopeks: number | null }) { + const { t } = useTranslation(); + + return ( + + +
+

+ {t('balance.topUpResult.awaitingPayment')} +

+

{t('balance.topUpResult.awaitingPaymentDesc')}

+
+ {amountKopeks != null && amountKopeks > 0 && ( + + )} +
+ ); +} + +function SuccessState({ amountKopeks }: { amountKopeks: number | null }) { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const handleGoToBalance = useCallback(() => { + navigate('/balance', { replace: true }); + }, [navigate]); + + return ( + + + +
+

{t('balance.topUpResult.success')}

+

{t('balance.topUpResult.successDesc')}

+
+ + {amountKopeks != null && amountKopeks > 0 && ( + + )} + + +
+ ); +} + +function FailedState({ amountKopeks }: { amountKopeks: number | null }) { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const handleTryAgain = useCallback(() => { + navigate('/balance', { replace: true }); + }, [navigate]); + + return ( + + + +
+

{t('balance.topUpResult.failed')}

+

{t('balance.topUpResult.failedDesc')}

+
+ + {amountKopeks != null && amountKopeks > 0 && ( + + )} + + +
+ ); +} + +function TimeoutState({ onRetry, onGoBack }: { onRetry: () => void; onGoBack: () => void }) { + const { t } = useTranslation(); + + return ( + +
+ +
+
+

{t('balance.topUpResult.timeout')}

+

{t('balance.topUpResult.timeoutDesc')}

+
+
+ + +
+
+ ); +} + +// ── Determine paid status from provider-specific status strings ── +const PAID_STATUSES = new Set([ + 'succeeded', + 'success', + 'paid', + 'paid_over', + 'completed', + 'confirmed', + 'closed', +]); + +const FAILED_STATUSES = new Set([ + 'fail', + 'failed', + 'error', + 'canceled', + 'cancelled', + 'declined', + 'expired', + 'cancel', +]); + +function isPaidStatus(status: string): boolean { + return PAID_STATUSES.has(status.toLowerCase()); +} + +function isFailedStatus(status: string): boolean { + return FAILED_STATUSES.has(status.toLowerCase()); +} + +// ── Main Component ─────────────────────────────────────────── + +export default function TopUpResult() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const queryClient = useQueryClient(); + const refreshUser = useAuthStore((state) => state.refreshUser); + const haptic = useHaptic(); + const pollStart = useRef(Date.now()); + const [pollTimedOut, setPollTimedOut] = useState(false); + const hapticFiredRef = useRef(false); + const cleanedUpRef = useRef(false); + + // Load saved payment info from sessionStorage (once on mount) + const [pendingInfo] = useState(() => loadTopUpPendingInfo()); + + // Detect if user arrived via redirect with success param (no polling needed) + const redirectStatus = searchParams.get('status') || searchParams.get('payment'); + const isRedirectSuccess = redirectStatus + ? isPaidStatus(redirectStatus) + : searchParams.get('success') === 'true'; + const isRedirectFailed = redirectStatus ? isFailedStatus(redirectStatus) : false; + + // Determine if we can poll (need method + numeric payment_id) + const parsedPaymentId = pendingInfo?.payment_id ? parseInt(pendingInfo.payment_id, 10) : NaN; + const canPoll = + !!(pendingInfo?.method_id && !isNaN(parsedPaymentId)) && + !isRedirectSuccess && + !isRedirectFailed; + + // Poll payment status + const { data: paymentStatus, refetch } = useQuery({ + queryKey: ['topup-status', pendingInfo?.method_id, parsedPaymentId], + queryFn: () => balanceApi.getPendingPayment(pendingInfo!.method_id, parsedPaymentId), + enabled: canPoll && !pollTimedOut, + refetchInterval: (query) => { + const payment = query.state.data; + if (!payment) return POLL_INTERVAL_MS; + + // Stop polling if paid or failed + if (payment.is_paid || isPaidStatus(payment.status) || isFailedStatus(payment.status)) { + return false; + } + + // Check timeout + if (Date.now() - pollStart.current > MAX_POLL_MS) { + setPollTimedOut(true); + return false; + } + + return POLL_INTERVAL_MS; + }, + retry: 2, + }); + + const handleRetryPoll = useCallback(() => { + pollStart.current = Date.now(); + setPollTimedOut(false); + refetch(); + }, [setPollTimedOut, refetch]); + + const handleGoBack = useCallback(() => { + clearTopUpPendingInfo(); + navigate('/balance', { replace: true }); + }, [navigate]); + + // Redirect to balance if no data at all + useEffect(() => { + if (!pendingInfo && !redirectStatus) { + navigate('/balance', { replace: true }); + } + }, [pendingInfo, redirectStatus, navigate]); + + // Determine current visual state + const amountKopeks = paymentStatus?.amount_kopeks ?? pendingInfo?.amount_kopeks ?? null; + + const resolvedPaid = + isRedirectSuccess || + paymentStatus?.is_paid || + (paymentStatus && isPaidStatus(paymentStatus.status)); + + const resolvedFailed = + isRedirectFailed || (paymentStatus && isFailedStatus(paymentStatus.status)); + + // Clean up sessionStorage and invalidate queries when payment resolves + useEffect(() => { + if (cleanedUpRef.current) return; + if (resolvedPaid) { + cleanedUpRef.current = true; + clearTopUpPendingInfo(); + queryClient.invalidateQueries({ queryKey: ['balance'] }); + queryClient.invalidateQueries({ queryKey: ['transactions'] }); + queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); + refreshUser(); + } else if (resolvedFailed) { + cleanedUpRef.current = true; + clearTopUpPendingInfo(); + } + }, [resolvedPaid, resolvedFailed, queryClient, refreshUser]); + + // Haptic feedback on status resolution (fire once) + useEffect(() => { + if (hapticFiredRef.current) return; + if (resolvedPaid) { + hapticFiredRef.current = true; + haptic.notification('success'); + } else if (resolvedFailed) { + hapticFiredRef.current = true; + haptic.notification('error'); + } + }, [resolvedPaid, resolvedFailed, haptic]); + + return ( +
+
+ {resolvedPaid ? ( + + ) : resolvedFailed ? ( + + ) : pollTimedOut ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/src/utils/topUpStorage.ts b/src/utils/topUpStorage.ts new file mode 100644 index 0000000..7eee91d --- /dev/null +++ b/src/utils/topUpStorage.ts @@ -0,0 +1,63 @@ +const STORAGE_KEY = 'topup_pending_payment'; +const MAX_AGE_MS = 30 * 60 * 1000; // 30 minutes + +export interface TopUpPendingInfo { + amount_kopeks: number; + method_id: string; + method_name: string; + payment_id: string; + created_at: number; // Date.now() +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +export function saveTopUpPendingInfo(info: TopUpPendingInfo) { + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(info)); + } catch { + // sessionStorage unavailable (private mode, quota, etc.) + } +} + +export function loadTopUpPendingInfo(): TopUpPendingInfo | null { + try { + const raw = sessionStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed: unknown = JSON.parse(raw); + if ( + !isRecord(parsed) || + typeof parsed.amount_kopeks !== 'number' || + typeof parsed.method_id !== 'string' || + typeof parsed.method_name !== 'string' || + typeof parsed.payment_id !== 'string' || + typeof parsed.created_at !== 'number' || + parsed.amount_kopeks <= 0 + ) { + return null; + } + // Discard stale entries + if (Date.now() - (parsed.created_at as number) > MAX_AGE_MS) { + clearTopUpPendingInfo(); + return null; + } + return { + amount_kopeks: parsed.amount_kopeks as number, + method_id: parsed.method_id as string, + method_name: parsed.method_name as string, + payment_id: parsed.payment_id as string, + created_at: parsed.created_at as number, + }; + } catch { + return null; + } +} + +export function clearTopUpPendingInfo() { + try { + sessionStorage.removeItem(STORAGE_KEY); + } catch { + // ignore + } +}