Merge pull request #264 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-03-09 05:06:49 +03:00
committed by GitHub
19 changed files with 930 additions and 179 deletions

View File

@@ -43,6 +43,7 @@ const PurchaseSuccess = lazy(() => import('./pages/PurchaseSuccess'));
const AutoLogin = lazy(() => import('./pages/AutoLogin')); const AutoLogin = lazy(() => import('./pages/AutoLogin'));
const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect')); const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect'));
const TopUpAmount = lazy(() => import('./pages/TopUpAmount')); const TopUpAmount = lazy(() => import('./pages/TopUpAmount'));
const TopUpResult = lazy(() => import('./pages/TopUpResult'));
const ConnectedAccounts = lazy(() => import('./pages/ConnectedAccounts')); const ConnectedAccounts = lazy(() => import('./pages/ConnectedAccounts'));
const LinkTelegramCallback = lazy(() => import('./pages/LinkTelegramCallback')); const LinkTelegramCallback = lazy(() => import('./pages/LinkTelegramCallback'));
const MergeAccounts = lazy(() => import('./pages/MergeAccounts')); const MergeAccounts = lazy(() => import('./pages/MergeAccounts'));
@@ -112,7 +113,13 @@ const AdminLandings = lazy(() => import('./pages/AdminLandings'));
const AdminLandingEditor = lazy(() => import('./pages/AdminLandingEditor')); const AdminLandingEditor = lazy(() => import('./pages/AdminLandingEditor'));
const AdminLandingStats = lazy(() => import('./pages/AdminLandingStats')); 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 isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const isLoading = useAuthStore((state) => state.isLoading); const isLoading = useAuthStore((state) => state.isLoading);
const location = useLocation(); const location = useLocation();
@@ -122,12 +129,11 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
} }
if (!isAuthenticated) { if (!isAuthenticated) {
// Сохраняем текущий URL для возврата после авторизации
saveReturnUrl(); saveReturnUrl();
return <Navigate to="/login" replace state={{ from: location.pathname }} />; return <Navigate to="/login" replace state={{ from: location.pathname }} />;
} }
return <Layout>{children}</Layout>; return withLayout ? <Layout>{children}</Layout> : <>{children}</>;
} }
function AdminRoute({ children }: { children: React.ReactNode }) { function AdminRoute({ children }: { children: React.ReactNode }) {
@@ -141,7 +147,6 @@ function AdminRoute({ children }: { children: React.ReactNode }) {
} }
if (!isAuthenticated) { if (!isAuthenticated) {
// Сохраняем текущий URL для возврата после авторизации
saveReturnUrl(); saveReturnUrl();
return <Navigate to="/login" replace state={{ from: location.pathname }} />; return <Navigate to="/login" replace state={{ from: location.pathname }} />;
} }
@@ -283,6 +288,18 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/balance/top-up/result"
element={
<ProtectedRoute withLayout={false}>
<ErrorBoundary level="app">
<LazyPage>
<TopUpResult />
</LazyPage>
</ErrorBoundary>
</ProtectedRoute>
}
/>
<Route <Route
path="/balance/top-up/:methodId" path="/balance/top-up/:methodId"
element={ element={

View File

@@ -109,7 +109,15 @@ export const balanceApi = {
// Get specific pending payment details // Get specific pending payment details
getPendingPayment: async (method: string, paymentId: number): Promise<PendingPayment> => { getPendingPayment: async (method: string, paymentId: number): Promise<PendingPayment> => {
const response = await apiClient.get<PendingPayment>( const response = await apiClient.get<PendingPayment>(
`/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<PendingPayment> => {
const response = await apiClient.get<PendingPayment>(
`/cabinet/balance/pending-payments/${encodeURIComponent(method)}/latest`,
); );
return response.data; return response.data;
}, },
@@ -117,7 +125,7 @@ export const balanceApi = {
// Manually check payment status // Manually check payment status
checkPaymentStatus: async (method: string, paymentId: number): Promise<ManualCheckResponse> => { checkPaymentStatus: async (method: string, paymentId: number): Promise<ManualCheckResponse> => {
const response = await apiClient.post<ManualCheckResponse>( const response = await apiClient.post<ManualCheckResponse>(
`/cabinet/balance/pending-payments/${method}/${paymentId}/check`, `/cabinet/balance/pending-payments/${encodeURIComponent(method)}/${encodeURIComponent(paymentId)}/check`,
); );
return response.data; return response.data;
}, },

View File

@@ -1,23 +1,98 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next'; 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 type { Subscription } from '../../types';
import { subscriptionApi } from '../../api/subscription';
import { useTheme } from '../../hooks/useTheme'; import { useTheme } from '../../hooks/useTheme';
import { useCurrency } from '../../hooks/useCurrency';
import { useHapticFeedback } from '../../platform/hooks/useHaptic';
import { getGlassColors } from '../../utils/glassTheme'; import { getGlassColors } from '../../utils/glassTheme';
import { getInsufficientBalanceError } from '../../utils/subscriptionHelpers';
interface SubscriptionCardExpiredProps { interface SubscriptionCardExpiredProps {
subscription: Subscription; 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 { t } = useTranslation();
const { isDark } = useTheme(); const { isDark } = useTheme();
const g = getGlassColors(isDark); 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<string | null>(null);
const formattedDate = new Date(subscription.end_date).toLocaleDateString(); 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 ( return (
<div <div
className="relative overflow-hidden rounded-3xl" className={`relative overflow-hidden rounded-3xl ${className ?? ''}`}
style={{ style={{
background: g.cardBg, background: g.cardBg,
border: isDark ? '1px solid rgba(255,70,70,0.12)' : '1px solid rgba(255,59,92,0.2)', border: isDark ? '1px solid rgba(255,70,70,0.12)' : '1px solid rgba(255,59,92,0.2)',
@@ -81,39 +156,121 @@ export default function SubscriptionCardExpired({ subscription }: SubscriptionCa
</svg> </svg>
</div> </div>
<h2 className="text-lg font-bold tracking-tight text-dark-50"> <h2 className="text-lg font-bold tracking-tight text-dark-50">
{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')}
</h2> </h2>
</div> </div>
{/* Expired date */} {/* Expired date + Balance row */}
<div <div
className="mb-5 flex items-center justify-center rounded-[14px]" className="mb-5 flex items-center justify-between rounded-[14px]"
style={{ style={{
background: 'rgba(255,59,92,0.04)', background: 'rgba(255,59,92,0.04)',
border: '1px solid rgba(255,59,92,0.08)', border: '1px solid rgba(255,59,92,0.08)',
padding: '14px 18px', padding: '14px 18px',
}} }}
> >
<div className="mb-0.5 font-mono text-[10px] font-medium uppercase tracking-wider text-dark-50/30"> <div className="flex items-center">
{t('dashboard.expired.expiredDate')} <div className="mb-0.5 font-mono text-[10px] font-medium uppercase tracking-wider text-dark-50/30">
{t('dashboard.expired.expiredDate')}
</div>
<div className="ml-3 text-base font-bold tracking-tight text-dark-50/50">
{formattedDate}
</div>
</div> </div>
<div className="ml-3 text-base font-bold tracking-tight text-dark-50/50"> <div className="flex items-center gap-1.5">
{formattedDate} <span className="text-[10px] font-medium uppercase tracking-wider text-dark-50/30">
{t('dashboard.expired.balance')}
</span>
<span
className={`text-sm font-semibold ${hasBalance ? 'text-success-400' : 'text-dark-50/30'}`}
>
{formatAmount(balanceRubles)} {currencySymbol}
</span>
</div> </div>
</div> </div>
{/* Renew error */}
{renewError && (
<div
className="mb-4 rounded-xl border border-error-500/30 bg-error-500/10 p-3 text-center text-sm text-error-400"
role="alert"
>
{renewError}
</div>
)}
{/* Action buttons */} {/* Action buttons */}
<div className="flex gap-2.5"> <div className="flex gap-2.5">
<Link {/* Quick Renew or Top Up button */}
to="/subscription/purchase" {hasBalance ? (
className="flex flex-1 items-center justify-center rounded-[14px] py-3.5 text-[15px] font-semibold tracking-tight text-white transition-all duration-300" <button
style={{ type="button"
background: 'linear-gradient(135deg, #FF3B5C, #FF6B35)', onClick={handleQuickRenew}
boxShadow: '0 4px 20px rgba(255,59,92,0.2)', disabled={isRenewing}
}} className="flex flex-1 items-center justify-center gap-2 rounded-[14px] py-3.5 text-[15px] font-semibold tracking-tight text-white transition-all duration-300 disabled:opacity-50"
> style={{
{t('dashboard.expired.renew')} background: 'linear-gradient(135deg, #FF3B5C, #FF6B35)',
</Link> boxShadow: '0 4px 20px rgba(255,59,92,0.2)',
}}
>
{isRenewing ? (
<span
className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white"
aria-hidden="true"
/>
) : (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
</svg>
)}
{isRenewing
? t('common.loading')
: isDisabledDaily
? t('dashboard.suspended.resume')
: t('dashboard.expired.quickRenew')}
</button>
) : (
<button
type="button"
onClick={handleTopUp}
className="flex flex-1 items-center justify-center gap-2 rounded-[14px] py-3.5 text-[15px] font-semibold tracking-tight text-white transition-all duration-300"
style={{
background: 'linear-gradient(135deg, #FF3B5C, #FF6B35)',
boxShadow: '0 4px 20px rgba(255,59,92,0.2)',
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{t('dashboard.expired.topUp')}
</button>
)}
{/* Renew (go to purchase page) */}
<Link <Link
to="/subscription/purchase" to="/subscription/purchase"
className="flex items-center justify-center rounded-[14px] px-5 py-3.5 text-[15px] font-semibold tracking-tight text-dark-50/50 transition-colors duration-200" className="flex items-center justify-center rounded-[14px] px-5 py-3.5 text-[15px] font-semibold tracking-tight text-dark-50/50 transition-colors duration-200"

View File

@@ -0,0 +1,37 @@
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
export function AnimatedCheckmark({ className }: { className?: string }) {
return (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 15, delay: 0.1 }}
className={cn(
'flex h-20 w-20 items-center justify-center rounded-full bg-success-500/10',
className,
)}
>
<motion.svg
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 0.3 }}
className="h-10 w-10 text-success-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
aria-hidden="true"
>
<motion.path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.4, delay: 0.3 }}
/>
</motion.svg>
</motion.div>
);
}

View File

@@ -0,0 +1,37 @@
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
export function AnimatedCrossmark({ className }: { className?: string }) {
return (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 15, delay: 0.1 }}
className={cn(
'flex h-20 w-20 items-center justify-center rounded-full bg-error-500/10',
className,
)}
>
<motion.svg
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: 0.3 }}
className="h-10 w-10 text-error-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<motion.path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.3, delay: 0.3 }}
/>
</motion.svg>
</motion.div>
);
}

View File

@@ -0,0 +1,17 @@
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
export function Spinner({ className }: { className?: string }) {
const { t } = useTranslation();
return (
<div
role="status"
aria-label={t('common.loading')}
className={cn(
'h-8 w-8 animate-spin rounded-full border-2 border-dark-600 border-t-accent-500',
className,
)}
/>
);
}

View File

@@ -254,11 +254,20 @@
"trialSubtitle": "Trial period ended", "trialSubtitle": "Trial period ended",
"paidSubtitle": "Subscription has expired", "paidSubtitle": "Subscription has expired",
"renew": "Renew Subscription", "renew": "Renew Subscription",
"quickRenew": "Quick Renew",
"insufficientFunds": "Insufficient balance",
"renewError": "Renewal error, try again",
"topUp": "Top up balance",
"balance": "Balance",
"tariffs": "Tariffs", "tariffs": "Tariffs",
"traffic": "Traffic", "traffic": "Traffic",
"devices": "Devices", "devices": "Devices",
"expiredDate": "Expired" "expiredDate": "Expired"
}, },
"suspended": {
"title": "Subscription Suspended",
"resume": "Resume"
},
"trialOffer": { "trialOffer": {
"freeTitle": "Free Trial Available", "freeTitle": "Free Trial Available",
"paidTitle": "Trial Subscription", "paidTitle": "Trial Subscription",
@@ -446,6 +455,7 @@
"pause": { "pause": {
"title": "Subscription Pause", "title": "Subscription Pause",
"paused": "Paused", "paused": "Paused",
"suspended": "Suspended (insufficient funds)",
"active": "Active", "active": "Active",
"pauseBtn": "Pause", "pauseBtn": "Pause",
"resumeBtn": "Resume", "resumeBtn": "Resume",
@@ -677,6 +687,19 @@
"paymentSuccess": { "paymentSuccess": {
"title": "Payment Successful", "title": "Payment Successful",
"message": "Your balance has been topped up successfully. The funds are now available." "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": { "referral": {

View File

@@ -219,7 +219,27 @@
"noActiveSubscription": "اشتراک فعال ندارید", "noActiveSubscription": "اشتراک فعال ندارید",
"devicesUsed": "دستگاه‌ها: {{used}} از {{total}}", "devicesUsed": "دستگاه‌ها: {{used}} از {{total}}",
"trafficUsed": "ترافیک: {{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": { "subscription": {
"title": "اشتراک", "title": "اشتراک",
@@ -428,6 +448,7 @@
"nextCharge": "تا کسر بعدی", "nextCharge": "تا کسر بعدی",
"pauseBtn": "توقف", "pauseBtn": "توقف",
"paused": "متوقف شده", "paused": "متوقف شده",
"suspended": "معلق شده (موجودی ناکافی)",
"pausedDescription": "کسر متوقف شد. اشتراک فعال خواهد بود تا", "pausedDescription": "کسر متوقف شد. اشتراک فعال خواهد بود تا",
"pausedInfo": "اشتراک متوقف شده", "pausedInfo": "اشتراک متوقف شده",
"resumeBtn": "ادامه", "resumeBtn": "ادامه",
@@ -511,6 +532,19 @@
"paymentSuccess": { "paymentSuccess": {
"title": "پرداخت موفق", "title": "پرداخت موفق",
"message": "موجودی شما با موفقیت شارژ شد. وجوه اکنون در دسترس است." "message": "موجودی شما با موفقیت شارژ شد. وجوه اکنون در دسترس است."
},
"topUpResult": {
"awaitingPayment": "در انتظار پرداخت",
"awaitingPaymentDesc": "ما منتظر تأیید پرداخت شما هستیم. این ممکن است چند دقیقه طول بکشد.",
"topUpAmount": "مبلغ شارژ",
"success": "شارژ موفق!",
"successDesc": "موجودی شما با موفقیت شارژ شد. وجوه اکنون در دسترس است.",
"failed": "پرداخت ناموفق",
"failedDesc": "متأسفانه پرداخت تکمیل نشد. لطفاً دوباره تلاش کنید یا روش پرداخت دیگری انتخاب کنید.",
"timeout": "بیشتر از حد معمول طول کشید",
"timeoutDesc": "پردازش پرداخت بیشتر از حد معمول طول می‌کشد. می‌توانید دوباره وضعیت را بررسی کنید.",
"goToBalance": "مشاهده موجودی",
"tryAgain": "تلاش مجدد"
} }
}, },
"referral": { "referral": {

View File

@@ -266,11 +266,20 @@
"trialSubtitle": "Пробный период завершён", "trialSubtitle": "Пробный период завершён",
"paidSubtitle": "Срок действия закончился", "paidSubtitle": "Срок действия закончился",
"renew": "Продлить подписку", "renew": "Продлить подписку",
"quickRenew": "Продлить",
"topUp": "Пополнить баланс",
"balance": "Баланс",
"insufficientFunds": "Недостаточно средств на балансе",
"renewError": "Ошибка продления, попробуйте ещё раз",
"tariffs": "Тарифы", "tariffs": "Тарифы",
"traffic": "Трафик", "traffic": "Трафик",
"devices": "Устройства", "devices": "Устройства",
"expiredDate": "Истекла" "expiredDate": "Истекла"
}, },
"suspended": {
"title": "Подписка приостановлена",
"resume": "Возобновить"
},
"trialOffer": { "trialOffer": {
"freeTitle": "Бесплатный пробный период", "freeTitle": "Бесплатный пробный период",
"paidTitle": "Пробная подписка", "paidTitle": "Пробная подписка",
@@ -469,6 +478,7 @@
"pause": { "pause": {
"title": "Пауза подписки", "title": "Пауза подписки",
"paused": "На паузе", "paused": "На паузе",
"suspended": "Приостановлена (недостаточно средств)",
"active": "Активна", "active": "Активна",
"pauseBtn": "Приостановить", "pauseBtn": "Приостановить",
"resumeBtn": "Возобновить", "resumeBtn": "Возобновить",
@@ -705,6 +715,19 @@
"paymentSuccess": { "paymentSuccess": {
"title": "Оплата прошла успешно", "title": "Оплата прошла успешно",
"message": "Ваш баланс успешно пополнен. Средства уже доступны." "message": "Ваш баланс успешно пополнен. Средства уже доступны."
},
"topUpResult": {
"awaitingPayment": "Ожидание оплаты",
"awaitingPaymentDesc": "Мы ожидаем подтверждение вашего платежа. Это может занять несколько минут.",
"topUpAmount": "Сумма пополнения",
"success": "Баланс пополнен!",
"successDesc": "Ваш баланс успешно пополнен. Средства уже доступны.",
"failed": "Оплата не прошла",
"failedDesc": "К сожалению, платёж не был завершён. Попробуйте ещё раз или выберите другой способ оплаты.",
"timeout": "Дольше, чем обычно",
"timeoutDesc": "Обработка платежа занимает больше времени. Вы можете проверить статус ещё раз.",
"goToBalance": "Перейти к балансу",
"tryAgain": "Попробовать снова"
} }
}, },
"referral": { "referral": {

View File

@@ -219,7 +219,27 @@
"noActiveSubscription": "无有效订阅", "noActiveSubscription": "无有效订阅",
"devicesUsed": "设备:{{used}} / {{total}}", "devicesUsed": "设备:{{used}} / {{total}}",
"trafficUsed": "流量:{{used}} / {{total}} GB", "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": { "subscription": {
"title": "订阅", "title": "订阅",
@@ -428,6 +448,7 @@
"nextCharge": "距下次扣费", "nextCharge": "距下次扣费",
"pauseBtn": "暂停", "pauseBtn": "暂停",
"paused": "已暂停", "paused": "已暂停",
"suspended": "已暂停(余额不足)",
"pausedDescription": "扣费已停止。订阅有效期至", "pausedDescription": "扣费已停止。订阅有效期至",
"pausedInfo": "订阅已暂停", "pausedInfo": "订阅已暂停",
"resumeBtn": "恢复", "resumeBtn": "恢复",
@@ -511,6 +532,19 @@
"paymentSuccess": { "paymentSuccess": {
"title": "支付成功", "title": "支付成功",
"message": "您的余额已成功充值,资金现已可用。" "message": "您的余额已成功充值,资金现已可用。"
},
"topUpResult": {
"awaitingPayment": "等待付款",
"awaitingPaymentDesc": "我们正在等待您的付款确认。这可能需要几分钟。",
"topUpAmount": "充值金额",
"success": "充值成功!",
"successDesc": "您的余额已成功充值,资金现已可用。",
"failed": "付款失败",
"failedDesc": "很遗憾,付款未完成。请重试或选择其他付款方式。",
"timeout": "处理时间较长",
"timeoutDesc": "付款处理时间比平时长。您可以再次检查状态。",
"goToBalance": "查看余额",
"tryAgain": "重试"
} }
}, },
"referral": { "referral": {

View File

@@ -8,12 +8,12 @@ import { useAuthStore } from '../store/auth';
import { balanceApi } from '../api/balance'; import { balanceApi } from '../api/balance';
import { useCurrency } from '../hooks/useCurrency'; import { useCurrency } from '../hooks/useCurrency';
import { API } from '../config/constants'; import { API } from '../config/constants';
import { useToast } from '../components/Toast';
import type { PaginatedResponse, Transaction } from '../types'; import type { PaginatedResponse, Transaction } from '../types';
import { Card } from '@/components/data-display/Card'; import { Card } from '@/components/data-display/Card';
import { Button } from '@/components/primitives/Button'; import { Button } from '@/components/primitives/Button';
import { staggerContainer, staggerItem } from '@/components/motion/transitions'; import { staggerContainer, staggerItem } from '@/components/motion/transitions';
import { isPaidStatus, isFailedStatus } from '../utils/paymentStatus';
// Icons // Icons
const ChevronDownIcon = ({ className = 'h-5 w-5' }: { className?: string }) => ( const ChevronDownIcon = ({ className = 'h-5 w-5' }: { className?: string }) => (
@@ -45,7 +45,6 @@ export default function Balance() {
const { formatAmount, currencySymbol } = useCurrency(); const { formatAmount, currencySymbol } = useCurrency();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { showToast } = useToast();
const paymentHandledRef = useRef(false); const paymentHandledRef = useRef(false);
// Fetch balance from API // Fetch balance from API
@@ -66,31 +65,19 @@ export default function Balance() {
if (paymentHandledRef.current) return; if (paymentHandledRef.current) return;
const paymentStatus = searchParams.get('payment') || searchParams.get('status'); const paymentStatus = searchParams.get('payment') || searchParams.get('status');
const isSuccess =
paymentStatus === 'success' || const normalised = paymentStatus?.toLowerCase() ?? '';
paymentStatus === 'paid' || const isSuccess = isPaidStatus(normalised) || searchParams.get('success') === 'true';
paymentStatus === 'completed' || const isFailed = isFailedStatus(normalised);
searchParams.get('success') === 'true';
if (isSuccess) { if (isSuccess) {
paymentHandledRef.current = true; paymentHandledRef.current = true;
navigate('/balance/top-up/result?status=success', { replace: true });
refetchBalance(); } else if (isFailed) {
refreshUser(); paymentHandledRef.current = true;
queryClient.invalidateQueries({ queryKey: ['transactions'] }); navigate('/balance/top-up/result?status=failed', { replace: true });
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 });
} }
}, [searchParams, navigate, refetchBalance, refreshUser, queryClient, showToast, t]); }, [searchParams, navigate]);
const [promocode, setPromocode] = useState(''); const [promocode, setPromocode] = useState('');
const [promocodeLoading, setPromocodeLoading] = useState(false); const [promocodeLoading, setPromocodeLoading] = useState(false);

View File

@@ -235,8 +235,12 @@ export default function Dashboard() {
<div className="skeleton h-12 w-full rounded-xl" /> <div className="skeleton h-12 w-full rounded-xl" />
</div> </div>
</div> </div>
) : subscription?.is_expired ? ( ) : subscription?.is_expired || subscription?.status === 'disabled' ? (
<SubscriptionCardExpired subscription={subscription} /> <SubscriptionCardExpired
subscription={subscription}
balanceKopeks={balanceData?.balance_kopeks ?? 0}
balanceRubles={balanceData?.balance_rubles ?? 0}
/>
) : subscription ? ( ) : subscription ? (
<SubscriptionCardActive <SubscriptionCardActive
subscription={subscription} subscription={subscription}

View File

@@ -8,6 +8,9 @@ import { landingApi } from '../api/landings';
import { authApi } from '../api/auth'; import { authApi } from '../api/auth';
import { useAuthStore } from '../store/auth'; import { useAuthStore } from '../store/auth';
import { copyToClipboard } from '../utils/clipboard'; 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'; import { cn } from '../lib/utils';
const MAX_POLL_MS = 10 * 60 * 1000; // 10 minutes const MAX_POLL_MS = 10 * 60 * 1000; // 10 minutes
@@ -16,17 +19,6 @@ const MAX_POLL_MS = 10 * 60 * 1000; // 10 minutes
// Sub-components // Sub-components
// ============================================================ // ============================================================
function Spinner({ className }: { className?: string }) {
return (
<div
className={cn(
'animate-spin rounded-full border-2 border-dark-600 border-t-accent-500',
className,
)}
/>
);
}
function PendingState() { function PendingState() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -135,33 +127,7 @@ function CabinetCredentialsState({
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center" className="flex flex-col items-center gap-6 text-center"
> >
{/* Animated checkmark */} <AnimatedCheckmark />
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 15, delay: 0.1 }}
className="flex h-20 w-20 items-center justify-center rounded-full bg-success-500/10"
>
<motion.svg
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{ duration: 0.4, delay: 0.3 }}
className="h-10 w-10 text-success-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
>
<motion.path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.4, delay: 0.3 }}
/>
</motion.svg>
</motion.div>
{/* Title */} {/* Title */}
<div> <div>
@@ -267,33 +233,7 @@ function SuccessState({
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center" className="flex flex-col items-center gap-6 text-center"
> >
{/* Animated checkmark */} <AnimatedCheckmark />
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 15, delay: 0.1 }}
className="flex h-20 w-20 items-center justify-center rounded-full bg-success-500/10"
>
<motion.svg
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{ duration: 0.4, delay: 0.3 }}
className="h-10 w-10 text-success-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
>
<motion.path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.4, delay: 0.3 }}
/>
</motion.svg>
</motion.div>
{/* Title */} {/* Title */}
<div> <div>
@@ -551,33 +491,7 @@ function GiftPendingActivationState({
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center" className="flex flex-col items-center gap-6 text-center"
> >
{/* Animated checkmark */} <AnimatedCheckmark />
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 15, delay: 0.1 }}
className="flex h-20 w-20 items-center justify-center rounded-full bg-success-500/10"
>
<motion.svg
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{ duration: 0.4, delay: 0.3 }}
className="h-10 w-10 text-success-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
>
<motion.path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.4, delay: 0.3 }}
/>
</motion.svg>
</motion.div>
<div> <div>
<h1 className="text-xl font-bold text-dark-50">{t('landing.giftSentSuccess')}</h1> <h1 className="text-xl font-bold text-dark-50">{t('landing.giftSentSuccess')}</h1>
@@ -631,17 +545,7 @@ function FailedState() {
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center" className="flex flex-col items-center gap-6 text-center"
> >
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-error-500/10"> <AnimatedCrossmark />
<svg
className="h-10 w-10 text-error-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<div> <div>
<h1 className="text-xl font-bold text-dark-50">{t('landing.purchaseFailed')}</h1> <h1 className="text-xl font-bold text-dark-50">{t('landing.purchaseFailed')}</h1>
<p className="mt-2 text-sm text-dark-400">{t('landing.purchaseFailedDesc')}</p> <p className="mt-2 text-sm text-dark-400">{t('landing.purchaseFailedDesc')}</p>
@@ -785,7 +689,11 @@ export default function PurchaseSuccess() {
return ( return (
<div className="flex min-h-dvh items-center justify-center bg-dark-950 px-4"> <div className="flex min-h-dvh items-center justify-center bg-dark-950 px-4">
<div className="w-full max-w-md rounded-2xl border border-dark-800/50 bg-dark-900/50 p-8"> <div
className="w-full max-w-md rounded-2xl border border-dark-800/50 bg-dark-900/50 p-8"
aria-live="polite"
aria-atomic="true"
>
{isError ? ( {isError ? (
<FailedState /> <FailedState />
) : isEmailSelfPurchase ? ( ) : isEmailSelfPurchase ? (

View File

@@ -211,6 +211,8 @@ export default function Subscription() {
const { data: purchaseOptions } = useQuery({ const { data: purchaseOptions } = useQuery({
queryKey: ['purchase-options'], queryKey: ['purchase-options'],
queryFn: subscriptionApi.getPurchaseOptions, queryFn: subscriptionApi.getPurchaseOptions,
staleTime: 0,
refetchOnMount: 'always',
}); });
const isTariffsMode = purchaseOptions?.sales_mode === 'tariffs'; const isTariffsMode = purchaseOptions?.sales_mode === 'tariffs';
@@ -250,6 +252,7 @@ export default function Subscription() {
mutationFn: () => subscriptionApi.togglePause(), mutationFn: () => subscriptionApi.togglePause(),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscription'] }); queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
}, },
}); });
@@ -275,6 +278,7 @@ export default function Subscription() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscription'] }); queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['devices'] }); queryClient.invalidateQueries({ queryKey: ['devices'] });
queryClient.invalidateQueries({ queryKey: ['device-price'] });
setShowDeviceTopup(false); setShowDeviceTopup(false);
setDevicesToAdd(1); setDevicesToAdd(1);
}, },
@@ -525,7 +529,9 @@ export default function Subscription() {
? subscription.is_trial ? subscription.is_trial
? t('subscription.trialStatus') ? t('subscription.trialStatus')
: t('subscription.active') : t('subscription.active')
: t('subscription.expired')} : subscription.status === 'disabled'
? t('subscription.pause.suspended')
: t('subscription.expired')}
</span> </span>
</div> </div>
@@ -982,9 +988,11 @@ export default function Subscription() {
{t('subscription.pause.title')} {t('subscription.pause.title')}
</h2> </h2>
<div className="mt-1 text-[12px] text-dark-50/35"> <div className="mt-1 text-[12px] text-dark-50/35">
{subscription.is_daily_paused {subscription.status === 'disabled'
? t('subscription.pause.paused') ? t('subscription.pause.suspended')
: t('subscription.pause.active')} : subscription.is_daily_paused
? t('subscription.pause.paused')
: t('subscription.pause.active')}
</div> </div>
</div> </div>
<button <button
@@ -992,20 +1000,25 @@ export default function Subscription() {
disabled={pauseMutation.isPending} disabled={pauseMutation.isPending}
className="rounded-[10px] px-4 py-2 text-sm font-semibold transition-colors duration-300" className="rounded-[10px] px-4 py-2 text-sm font-semibold transition-colors duration-300"
style={{ style={{
background: subscription.is_daily_paused background:
? 'rgba(var(--color-accent-400), 0.12)' subscription.is_daily_paused || subscription.status === 'disabled'
: 'rgba(255,184,0,0.12)', ? 'rgba(var(--color-accent-400), 0.12)'
border: subscription.is_daily_paused : 'rgba(255,184,0,0.12)',
? '1px solid rgba(var(--color-accent-400), 0.2)' border:
: '1px solid rgba(255,184,0,0.2)', subscription.is_daily_paused || subscription.status === 'disabled'
color: subscription.is_daily_paused ? 'rgb(var(--color-accent-400))' : '#FFB800', ? '1px solid rgba(var(--color-accent-400), 0.2)'
: '1px solid rgba(255,184,0,0.2)',
color:
subscription.is_daily_paused || subscription.status === 'disabled'
? 'rgb(var(--color-accent-400))'
: '#FFB800',
}} }}
> >
{pauseMutation.isPending ? ( {pauseMutation.isPending ? (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" /> <span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
</span> </span>
) : subscription.is_daily_paused ? ( ) : subscription.is_daily_paused || subscription.status === 'disabled' ? (
t('subscription.pause.resumeBtn') t('subscription.pause.resumeBtn')
) : ( ) : (
t('subscription.pause.pauseBtn') t('subscription.pause.pauseBtn')
@@ -1186,7 +1199,7 @@ export default function Subscription() {
</div> </div>
{/* Check if completely unavailable (no subscription, price not set, etc.) */} {/* Check if completely unavailable (no subscription, price not set, etc.) */}
{devicePriceData?.available === false && !devicePriceData?.max_device_limit ? ( {devicePriceData?.available === false ? (
<div className="py-4 text-center text-sm text-dark-400"> <div className="py-4 text-center text-sm text-dark-400">
{devicePriceData.reason || {devicePriceData.reason ||
t('subscription.additionalOptions.devicesUnavailable')} t('subscription.additionalOptions.devicesUnavailable')}
@@ -1235,13 +1248,6 @@ export default function Subscription() {
</div> </div>
)} )}
{/* Show reason if can't add requested amount */}
{devicePriceData?.available === false && devicePriceData?.reason && (
<div className="rounded-lg bg-warning-500/10 p-3 text-center text-sm text-warning-400">
{devicePriceData.reason}
</div>
)}
{/* Price info - only when available */} {/* Price info - only when available */}
{devicePriceData?.available && devicePriceData.price_per_device_label && ( {devicePriceData?.available && devicePriceData.price_per_device_label && (
<div className="text-center"> <div className="text-center">

View File

@@ -48,6 +48,8 @@ export default function SubscriptionPurchase() {
const { data: purchaseOptions, isLoading: optionsLoading } = useQuery({ const { data: purchaseOptions, isLoading: optionsLoading } = useQuery({
queryKey: ['purchase-options'], queryKey: ['purchase-options'],
queryFn: subscriptionApi.getPurchaseOptions, queryFn: subscriptionApi.getPurchaseOptions,
staleTime: 0,
refetchOnMount: 'always',
}); });
// Active promo discount // Active promo discount

View File

@@ -12,6 +12,7 @@ import { useHaptic, usePlatform } from '@/platform';
import { staggerContainer, staggerItem } from '@/components/motion/transitions'; import { staggerContainer, staggerItem } from '@/components/motion/transitions';
import type { PaymentMethod } from '../types'; import type { PaymentMethod } from '../types';
import BentoCard from '../components/ui/BentoCard'; import BentoCard from '../components/ui/BentoCard';
import { saveTopUpPendingInfo } from '../utils/topUpStorage';
// Icons // Icons
const StarIcon = () => ( const StarIcon = () => (
@@ -202,6 +203,20 @@ export default function TopUpAmount() {
const redirectUrl = data.payment_url || data.invoice_url; const redirectUrl = data.payment_url || data.invoice_url;
if (redirectUrl) { if (redirectUrl) {
setPaymentUrl(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) => { onError: (err: unknown) => {
@@ -296,8 +311,8 @@ export default function TopUpAmount() {
await navigator.clipboard.writeText(paymentUrl); await navigator.clipboard.writeText(paymentUrl);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} catch (e) { } catch {
console.warn('Failed to copy:', e); // Clipboard write failed silently
} }
}; };

348
src/pages/TopUpResult.tsx Normal file
View File

@@ -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 (
<div className="mt-4 rounded-xl bg-dark-800/50 px-6 py-4">
<p className="text-xs text-dark-400">{label}</p>
<p className="mt-1 text-2xl font-bold text-dark-50">
{formatAmount(amountRubles)} <span className="text-lg text-dark-400">{currencySymbol}</span>
</p>
</div>
);
}
function PendingState({ amountKopeks }: { amountKopeks: number | null }) {
const { t } = useTranslation();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<Spinner className="h-16 w-16 border-[3px]" />
<div>
<h1 className="text-xl font-bold text-dark-50">
{t('balance.topUpResult.awaitingPayment')}
</h1>
<p className="mt-2 text-sm text-dark-400">{t('balance.topUpResult.awaitingPaymentDesc')}</p>
</div>
{amountKopeks != null && amountKopeks > 0 && (
<AmountDisplay amountKopeks={amountKopeks} label={t('balance.topUpResult.topUpAmount')} />
)}
</motion.div>
);
}
function SuccessState({ amountKopeks }: { amountKopeks: number | null }) {
const { t } = useTranslation();
const navigate = useNavigate();
const handleGoToBalance = useCallback(() => {
navigate('/balance', { replace: true });
}, [navigate]);
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<AnimatedCheckmark />
<div>
<h1 className="text-xl font-bold text-dark-50">{t('balance.topUpResult.success')}</h1>
<p className="mt-2 text-sm text-dark-400">{t('balance.topUpResult.successDesc')}</p>
</div>
{amountKopeks != null && amountKopeks > 0 && (
<AmountDisplay amountKopeks={amountKopeks} label={t('balance.topUpResult.topUpAmount')} />
)}
<button
type="button"
onClick={handleGoToBalance}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
>
{t('balance.topUpResult.goToBalance')}
</button>
</motion.div>
);
}
function FailedState({ amountKopeks }: { amountKopeks: number | null }) {
const { t } = useTranslation();
const navigate = useNavigate();
const handleTryAgain = useCallback(() => {
navigate('/balance', { replace: true });
}, [navigate]);
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<AnimatedCrossmark />
<div>
<h1 className="text-xl font-bold text-dark-50">{t('balance.topUpResult.failed')}</h1>
<p className="mt-2 text-sm text-dark-400">{t('balance.topUpResult.failedDesc')}</p>
</div>
{amountKopeks != null && amountKopeks > 0 && (
<AmountDisplay amountKopeks={amountKopeks} label={t('balance.topUpResult.topUpAmount')} />
)}
<button
type="button"
onClick={handleTryAgain}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-dark-800/50 px-6 py-3 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-700/50"
>
{t('balance.topUpResult.tryAgain')}
</button>
</motion.div>
);
}
function TimeoutState({ onRetry, onGoBack }: { onRetry: () => void; onGoBack: () => void }) {
const { t } = useTranslation();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-dark-800/50">
<svg
className="h-10 w-10 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<h1 className="text-xl font-bold text-dark-50">{t('balance.topUpResult.timeout')}</h1>
<p className="mt-2 text-sm text-dark-400">{t('balance.topUpResult.timeoutDesc')}</p>
</div>
<div className="flex w-full flex-col gap-3">
<button
type="button"
onClick={onRetry}
className="w-full rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
>
{t('common.retry')}
</button>
<button
type="button"
onClick={onGoBack}
className="w-full rounded-xl bg-dark-800/50 px-6 py-3 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-700/50"
>
{t('balance.topUpResult.goToBalance')}
</button>
</div>
</motion.div>
);
}
// ── 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 (
<div className="flex min-h-dvh items-center justify-center bg-dark-950 px-4">
<div
className="w-full max-w-md rounded-2xl border border-dark-800/50 bg-dark-900/50 p-8"
aria-live="polite"
aria-atomic="true"
>
{resolvedPaid ? (
<SuccessState amountKopeks={amountKopeks} />
) : resolvedFailed ? (
<FailedState amountKopeks={amountKopeks} />
) : pollTimedOut ? (
<TimeoutState onRetry={handleRetryPoll} onGoBack={handleGoBack} />
) : (
<PendingState amountKopeks={amountKopeks} />
)}
</div>
</div>
);
}

View File

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

63
src/utils/topUpStorage.ts Normal file
View File

@@ -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<string, unknown> {
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
}
}