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; + }, + + // Get latest pending payment by method (fallback when sessionStorage unavailable) + getLatestPayment: async (method: string): Promise => { + const response = await apiClient.get( + `/cabinet/balance/pending-payments/${encodeURIComponent(method)}/latest`, ); return response.data; }, @@ -117,7 +125,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/dashboard/SubscriptionCardExpired.tsx b/src/components/dashboard/SubscriptionCardExpired.tsx index 38ef1a1..5812312 100644 --- a/src/components/dashboard/SubscriptionCardExpired.tsx +++ b/src/components/dashboard/SubscriptionCardExpired.tsx @@ -1,23 +1,98 @@ +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Link } from 'react-router'; +import { Link, useNavigate, useLocation } from 'react-router'; +import { useQueryClient } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; import type { Subscription } from '../../types'; +import { subscriptionApi } from '../../api/subscription'; import { useTheme } from '../../hooks/useTheme'; +import { useCurrency } from '../../hooks/useCurrency'; +import { useHapticFeedback } from '../../platform/hooks/useHaptic'; import { getGlassColors } from '../../utils/glassTheme'; +import { getInsufficientBalanceError } from '../../utils/subscriptionHelpers'; interface SubscriptionCardExpiredProps { subscription: Subscription; + balanceKopeks?: number; + balanceRubles?: number; + className?: string; } -export default function SubscriptionCardExpired({ subscription }: SubscriptionCardExpiredProps) { +export default function SubscriptionCardExpired({ + subscription, + balanceKopeks = 0, + balanceRubles = 0, + className, +}: SubscriptionCardExpiredProps) { const { t } = useTranslation(); const { isDark } = useTheme(); const g = getGlassColors(isDark); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const location = useLocation(); + const { formatAmount, currencySymbol } = useCurrency(); + const haptic = useHapticFeedback(); + + const [isRenewing, setIsRenewing] = useState(false); + const [renewError, setRenewError] = useState(null); const formattedDate = new Date(subscription.end_date).toLocaleDateString(); + // Detect DISABLED daily subscription (suspended by system due to insufficient balance) + const isDisabledDaily = subscription.status === 'disabled' && subscription.is_daily; + + // For disabled daily subs, check if balance covers daily price + const dailyPrice = subscription.daily_price_kopeks ?? 0; + const hasBalance = isDisabledDaily + ? balanceKopeks >= dailyPrice && dailyPrice > 0 + : balanceKopeks >= 100; + + const handleQuickRenew = async () => { + setIsRenewing(true); + setRenewError(null); + haptic.buttonPressHeavy(); + + try { + if (isDisabledDaily) { + // Resume daily subscription via toggle pause endpoint + await subscriptionApi.togglePause(); + } else { + await subscriptionApi.renewSubscription(30); + } + haptic.success(); + queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ queryKey: ['balance'] }); + queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); + } catch (err: unknown) { + haptic.error(); + const insufficientData = getInsufficientBalanceError(err); + if (insufficientData) { + setRenewError(t('dashboard.expired.insufficientFunds')); + } else if (err instanceof AxiosError) { + const detail = err.response?.data?.detail; + if (typeof detail === 'string') { + setRenewError(detail); + } else { + setRenewError(t('dashboard.expired.renewError')); + } + } else { + setRenewError(t('dashboard.expired.renewError')); + } + } finally { + setIsRenewing(false); + } + }; + + const handleTopUp = () => { + haptic.buttonPress(); + const params = new URLSearchParams(); + params.set('returnTo', location.pathname); + navigate(`/balance/top-up?${params.toString()}`); + }; + return (

- {subscription.is_trial ? t('dashboard.expired.trialTitle') : t('dashboard.expired.title')} + {isDisabledDaily + ? t('dashboard.suspended.title') + : subscription.is_trial + ? t('dashboard.expired.trialTitle') + : t('dashboard.expired.title')}

- {/* Expired date */} + {/* Expired date + Balance row */}
-
- {t('dashboard.expired.expiredDate')} +
+
+ {t('dashboard.expired.expiredDate')} +
+
+ {formattedDate} +
-
- {formattedDate} +
+ + {t('dashboard.expired.balance')} + + + {formatAmount(balanceRubles)} {currencySymbol} +
+ {/* Renew error */} + {renewError && ( +
+ {renewError} +
+ )} + {/* Action buttons */}
- - {t('dashboard.expired.renew')} - + {/* Quick Renew or Top Up button */} + {hasBalance ? ( + + ) : ( + + )} + + {/* Renew (go to purchase page) */} + + + ); +} 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 130143d..094646a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -254,11 +254,20 @@ "trialSubtitle": "Trial period ended", "paidSubtitle": "Subscription has expired", "renew": "Renew Subscription", + "quickRenew": "Quick Renew", + "insufficientFunds": "Insufficient balance", + "renewError": "Renewal error, try again", + "topUp": "Top up balance", + "balance": "Balance", "tariffs": "Tariffs", "traffic": "Traffic", "devices": "Devices", "expiredDate": "Expired" }, + "suspended": { + "title": "Subscription Suspended", + "resume": "Resume" + }, "trialOffer": { "freeTitle": "Free Trial Available", "paidTitle": "Trial Subscription", @@ -446,6 +455,7 @@ "pause": { "title": "Subscription Pause", "paused": "Paused", + "suspended": "Suspended (insufficient funds)", "active": "Active", "pauseBtn": "Pause", "resumeBtn": "Resume", @@ -677,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 89df8b6..93f9501 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -219,7 +219,27 @@ "noActiveSubscription": "اشتراک فعال ندارید", "devicesUsed": "دستگاه‌ها: {{used}} از {{total}}", "trafficUsed": "ترافیک: {{used}} از {{total}} گیگ", - "unlimitedTraffic": "ترافیک نامحدود" + "unlimitedTraffic": "ترافیک نامحدود", + "expired": { + "title": "اشتراک منقضی شده", + "trialTitle": "دوره آزمایشی منقضی شده", + "trialSubtitle": "دوره آزمایشی پایان یافته", + "paidSubtitle": "اشتراک منقضی شده", + "renew": "تمدید اشتراک", + "quickRenew": "تمدید سریع", + "insufficientFunds": "موجودی ناکافی", + "renewError": "خطا در تمدید، دوباره تلاش کنید", + "topUp": "شارژ موجودی", + "balance": "موجودی", + "tariffs": "تعرفه‌ها", + "traffic": "ترافیک", + "devices": "دستگاه‌ها", + "expiredDate": "منقضی شده" + }, + "suspended": { + "title": "اشتراک معلق شده", + "resume": "از سرگیری" + } }, "subscription": { "title": "اشتراک", @@ -428,6 +448,7 @@ "nextCharge": "تا کسر بعدی", "pauseBtn": "توقف", "paused": "متوقف شده", + "suspended": "معلق شده (موجودی ناکافی)", "pausedDescription": "کسر متوقف شد. اشتراک فعال خواهد بود تا", "pausedInfo": "اشتراک متوقف شده", "resumeBtn": "ادامه", @@ -511,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 4f5022e..9fc892c 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -266,11 +266,20 @@ "trialSubtitle": "Пробный период завершён", "paidSubtitle": "Срок действия закончился", "renew": "Продлить подписку", + "quickRenew": "Продлить", + "topUp": "Пополнить баланс", + "balance": "Баланс", + "insufficientFunds": "Недостаточно средств на балансе", + "renewError": "Ошибка продления, попробуйте ещё раз", "tariffs": "Тарифы", "traffic": "Трафик", "devices": "Устройства", "expiredDate": "Истекла" }, + "suspended": { + "title": "Подписка приостановлена", + "resume": "Возобновить" + }, "trialOffer": { "freeTitle": "Бесплатный пробный период", "paidTitle": "Пробная подписка", @@ -469,6 +478,7 @@ "pause": { "title": "Пауза подписки", "paused": "На паузе", + "suspended": "Приостановлена (недостаточно средств)", "active": "Активна", "pauseBtn": "Приостановить", "resumeBtn": "Возобновить", @@ -705,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 23640a2..008aabf 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -219,7 +219,27 @@ "noActiveSubscription": "无有效订阅", "devicesUsed": "设备:{{used}} / {{total}}", "trafficUsed": "流量:{{used}} / {{total}} GB", - "unlimitedTraffic": "无限流量" + "unlimitedTraffic": "无限流量", + "expired": { + "title": "订阅已过期", + "trialTitle": "试用已过期", + "trialSubtitle": "试用期已结束", + "paidSubtitle": "订阅已到期", + "renew": "续订", + "quickRenew": "快速续订", + "insufficientFunds": "余额不足", + "renewError": "续订失败,请重试", + "topUp": "充值", + "balance": "余额", + "tariffs": "套餐", + "traffic": "流量", + "devices": "设备", + "expiredDate": "过期时间" + }, + "suspended": { + "title": "订阅已暂停", + "resume": "恢复" + } }, "subscription": { "title": "订阅", @@ -428,6 +448,7 @@ "nextCharge": "距下次扣费", "pauseBtn": "暂停", "paused": "已暂停", + "suspended": "已暂停(余额不足)", "pausedDescription": "扣费已停止。订阅有效期至", "pausedInfo": "订阅已暂停", "resumeBtn": "恢复", @@ -511,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..a093431 100644 --- a/src/pages/Balance.tsx +++ b/src/pages/Balance.tsx @@ -8,12 +8,12 @@ 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'; import { Button } from '@/components/primitives/Button'; import { staggerContainer, staggerItem } from '@/components/motion/transitions'; +import { isPaidStatus, isFailedStatus } from '../utils/paymentStatus'; // Icons const ChevronDownIcon = ({ className = 'h-5 w-5' }: { className?: string }) => ( @@ -45,7 +45,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 @@ -66,31 +65,19 @@ export default function Balance() { if (paymentHandledRef.current) return; const paymentStatus = searchParams.get('payment') || searchParams.get('status'); - const isSuccess = - paymentStatus === 'success' || - paymentStatus === 'paid' || - paymentStatus === 'completed' || - searchParams.get('success') === 'true'; + + const normalised = paymentStatus?.toLowerCase() ?? ''; + const isSuccess = isPaidStatus(normalised) || searchParams.get('success') === 'true'; + const isFailed = isFailedStatus(normalised); 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/Dashboard.tsx b/src/pages/Dashboard.tsx index 2861117..0cc715c 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -235,8 +235,12 @@ export default function Dashboard() {
- ) : subscription?.is_expired ? ( - + ) : subscription?.is_expired || subscription?.status === 'disabled' ? ( + ) : subscription ? ( - ); -} - 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/Subscription.tsx b/src/pages/Subscription.tsx index 6e0a54a..64ec03c 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -211,6 +211,8 @@ export default function Subscription() { const { data: purchaseOptions } = useQuery({ queryKey: ['purchase-options'], queryFn: subscriptionApi.getPurchaseOptions, + staleTime: 0, + refetchOnMount: 'always', }); const isTariffsMode = purchaseOptions?.sales_mode === 'tariffs'; @@ -250,6 +252,7 @@ export default function Subscription() { mutationFn: () => subscriptionApi.togglePause(), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ queryKey: ['balance'] }); }, }); @@ -275,6 +278,7 @@ export default function Subscription() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['subscription'] }); queryClient.invalidateQueries({ queryKey: ['devices'] }); + queryClient.invalidateQueries({ queryKey: ['device-price'] }); setShowDeviceTopup(false); setDevicesToAdd(1); }, @@ -525,7 +529,9 @@ export default function Subscription() { ? subscription.is_trial ? t('subscription.trialStatus') : t('subscription.active') - : t('subscription.expired')} + : subscription.status === 'disabled' + ? t('subscription.pause.suspended') + : t('subscription.expired')}
@@ -982,9 +988,11 @@ export default function Subscription() { {t('subscription.pause.title')}
- {subscription.is_daily_paused - ? t('subscription.pause.paused') - : t('subscription.pause.active')} + {subscription.status === 'disabled' + ? t('subscription.pause.suspended') + : subscription.is_daily_paused + ? t('subscription.pause.paused') + : t('subscription.pause.active')}
{/* Check if completely unavailable (no subscription, price not set, etc.) */} - {devicePriceData?.available === false && !devicePriceData?.max_device_limit ? ( + {devicePriceData?.available === false ? (
{devicePriceData.reason || t('subscription.additionalOptions.devicesUnavailable')} @@ -1235,13 +1248,6 @@ export default function Subscription() {
)} - {/* Show reason if can't add requested amount */} - {devicePriceData?.available === false && devicePriceData?.reason && ( -
- {devicePriceData.reason} -
- )} - {/* Price info - only when available */} {devicePriceData?.available && devicePriceData.price_per_device_label && (
diff --git a/src/pages/SubscriptionPurchase.tsx b/src/pages/SubscriptionPurchase.tsx index f7b99d5..2bc0f50 100644 --- a/src/pages/SubscriptionPurchase.tsx +++ b/src/pages/SubscriptionPurchase.tsx @@ -48,6 +48,8 @@ export default function SubscriptionPurchase() { const { data: purchaseOptions, isLoading: optionsLoading } = useQuery({ queryKey: ['purchase-options'], queryFn: subscriptionApi.getPurchaseOptions, + staleTime: 0, + refetchOnMount: 'always', }); // Active promo discount 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..783a308 --- /dev/null +++ b/src/pages/TopUpResult.tsx @@ -0,0 +1,348 @@ +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'; +import { isPaidStatus, isFailedStatus } from '../utils/paymentStatus'; + +// ── 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')}

+
+
+ + +
+
+ ); +} + +// ── 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()); + + // Fallback: read method from query params (for external browser redirects where sessionStorage is unavailable) + const methodFromUrl = searchParams.get('method'); + + // 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 by specific payment_id (need method + numeric payment_id) + const parsedPaymentId = pendingInfo?.payment_id ? parseInt(pendingInfo.payment_id, 10) : NaN; + const canPollById = + !!(pendingInfo?.method_id && !isNaN(parsedPaymentId)) && + !isRedirectSuccess && + !isRedirectFailed; + + // Fallback: poll by method via /latest endpoint when no sessionStorage data + const canPollByMethod = + !canPollById && !!methodFromUrl && !isRedirectSuccess && !isRedirectFailed; + + // Poll payment status by specific ID (primary path — sessionStorage available) + const { data: paymentStatus, refetch } = useQuery({ + queryKey: ['topup-status', pendingInfo?.method_id, parsedPaymentId], + queryFn: () => balanceApi.getPendingPayment(pendingInfo!.method_id, parsedPaymentId), + enabled: canPollById && !pollTimedOut, + refetchInterval: (query) => { + const payment = query.state.data; + if (!payment) return POLL_INTERVAL_MS; + + if (payment.is_paid || isPaidStatus(payment.status) || isFailedStatus(payment.status)) { + return false; + } + + if (Date.now() - pollStart.current > MAX_POLL_MS) { + setPollTimedOut(true); + return false; + } + + return POLL_INTERVAL_MS; + }, + retry: 2, + }); + + // Poll payment status by method latest (fallback — external browser, no sessionStorage) + const { data: latestPayment, refetch: refetchLatest } = useQuery({ + queryKey: ['topup-status-latest', methodFromUrl], + queryFn: () => balanceApi.getLatestPayment(methodFromUrl!), + enabled: canPollByMethod && !pollTimedOut, + refetchInterval: (query) => { + const payment = query.state.data; + if (!payment) return POLL_INTERVAL_MS; + + if (payment.is_paid || isPaidStatus(payment.status) || isFailedStatus(payment.status)) { + return false; + } + + if (Date.now() - pollStart.current > MAX_POLL_MS) { + setPollTimedOut(true); + return false; + } + + return POLL_INTERVAL_MS; + }, + retry: 2, + }); + + // Merge both polling sources + const effectivePayment = paymentStatus ?? latestPayment; + + const handleRetryPoll = useCallback(() => { + pollStart.current = Date.now(); + setPollTimedOut(false); + if (canPollById) { + refetch(); + } else { + refetchLatest(); + } + }, [canPollById, setPollTimedOut, refetch, refetchLatest]); + + const handleGoBack = useCallback(() => { + clearTopUpPendingInfo(); + navigate('/balance', { replace: true }); + }, [navigate]); + + // Redirect to balance if absolutely no data source available + useEffect(() => { + if (!pendingInfo && !redirectStatus && !methodFromUrl) { + navigate('/balance', { replace: true }); + } + }, [pendingInfo, redirectStatus, methodFromUrl, navigate]); + + // Determine current visual state + const amountKopeks = effectivePayment?.amount_kopeks ?? pendingInfo?.amount_kopeks ?? null; + + const resolvedPaid = + isRedirectSuccess || + effectivePayment?.is_paid || + (effectivePayment && isPaidStatus(effectivePayment.status)); + + const resolvedFailed = + isRedirectFailed || (effectivePayment && isFailedStatus(effectivePayment.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/paymentStatus.ts b/src/utils/paymentStatus.ts new file mode 100644 index 0000000..7f360f5 --- /dev/null +++ b/src/utils/paymentStatus.ts @@ -0,0 +1,31 @@ +export const PAID_STATUSES = new Set([ + 'succeeded', + 'success', + 'paid', + 'paid_over', + 'overpaid', + 'completed', + 'confirmed', + 'closed', +]); + +export const FAILED_STATUSES = new Set([ + 'fail', + 'failed', + 'error', + 'canceled', + 'cancelled', + 'declined', + 'expired', + 'cancel', + 'system_fail', + 'refund_paid', +]); + +export function isPaidStatus(status: string): boolean { + return PAID_STATUSES.has(status.toLowerCase()); +} + +export function isFailedStatus(status: string): boolean { + return FAILED_STATUSES.has(status.toLowerCase()); +} 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 + } +}