From 7940410d7d913e8c92a7732f4fdc4ababd06ba3b Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 8 Mar 2026 21:35:00 +0300 Subject: [PATCH 1/7] feat: support disabled daily subscription status in cabinet UI - Show SubscriptionCardExpired for disabled daily subscriptions - Use togglePause() API for resume instead of renewSubscription() - Add "Suspended" status badge and balance query invalidation - Add dashboard.suspended and subscription.pause.suspended translations (ru, en, zh, fa) --- .../dashboard/SubscriptionCardExpired.tsx | 197 ++++++++++++++++-- src/locales/en.json | 10 + src/locales/fa.json | 23 +- src/locales/ru.json | 10 + src/locales/zh.json | 23 +- src/pages/Dashboard.tsx | 8 +- src/pages/Subscription.tsx | 34 +-- 7 files changed, 269 insertions(+), 36 deletions(-) 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) */}
- ) : subscription?.is_expired ? ( - + ) : subscription?.is_expired || subscription?.status === 'disabled' ? ( + ) : subscription ? ( subscriptionApi.togglePause(), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ queryKey: ['balance'] }); }, }); @@ -525,7 +526,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 +985,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')}
+ + ); +} + +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 + } +} From 8897561fb2af322b4b37b84ac07b7746fde70586 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 9 Mar 2026 04:08:28 +0300 Subject: [PATCH 5/7] fix: cover all payment provider statuses in TopUpResult Add overpaid (PAL24) to PAID_STATUSES, system_fail and refund_paid (Heleket) to FAILED_STATUSES. Harmonize Balance.tsx redirect detection with the same full status sets and case-insensitive matching. --- src/pages/Balance.tsx | 32 +++++++++++++++++++++++++------- src/pages/TopUpResult.tsx | 3 +++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/pages/Balance.tsx b/src/pages/Balance.tsx index 725d9b9..d3ab517 100644 --- a/src/pages/Balance.tsx +++ b/src/pages/Balance.tsx @@ -64,14 +64,32 @@ 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 paidSet = new Set([ + 'succeeded', + 'success', + 'paid', + 'paid_over', + 'overpaid', + 'completed', + 'confirmed', + 'closed', + ]); + const failedSet = new Set([ + 'fail', + 'failed', + 'error', + 'canceled', + 'cancelled', + 'declined', + 'expired', + 'cancel', + 'system_fail', + 'refund_paid', + ]); - const isFailed = - paymentStatus === 'failed' || paymentStatus === 'error' || paymentStatus === 'canceled'; + const normalised = paymentStatus?.toLowerCase() ?? ''; + const isSuccess = paidSet.has(normalised) || searchParams.get('success') === 'true'; + const isFailed = failedSet.has(normalised); if (isSuccess) { paymentHandledRef.current = true; diff --git a/src/pages/TopUpResult.tsx b/src/pages/TopUpResult.tsx index 690f141..a0b0b35 100644 --- a/src/pages/TopUpResult.tsx +++ b/src/pages/TopUpResult.tsx @@ -183,6 +183,7 @@ const PAID_STATUSES = new Set([ 'success', 'paid', 'paid_over', + 'overpaid', 'completed', 'confirmed', 'closed', @@ -197,6 +198,8 @@ const FAILED_STATUSES = new Set([ 'declined', 'expired', 'cancel', + 'system_fail', + 'refund_paid', ]); function isPaidStatus(status: string): boolean { From 7ce5341e955ba34e7336959b09a528269e6b3417 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 9 Mar 2026 04:34:37 +0300 Subject: [PATCH 6/7] fix: support method query param fallback for external browser redirects When payment providers redirect to external browser, sessionStorage is unavailable. TopUpResult now reads method from URL query params and polls via /latest endpoint as fallback. --- src/api/balance.ts | 8 +++++ src/pages/TopUpResult.tsx | 65 ++++++++++++++++++++++++++++++--------- 2 files changed, 58 insertions(+), 15 deletions(-) diff --git a/src/api/balance.ts b/src/api/balance.ts index e9b3f86..f195571 100644 --- a/src/api/balance.ts +++ b/src/api/balance.ts @@ -114,6 +114,14 @@ export const balanceApi = { 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; + }, + // Manually check payment status checkPaymentStatus: async (method: string, paymentId: number): Promise => { const response = await apiClient.post( diff --git a/src/pages/TopUpResult.tsx b/src/pages/TopUpResult.tsx index a0b0b35..ac46a55 100644 --- a/src/pages/TopUpResult.tsx +++ b/src/pages/TopUpResult.tsx @@ -226,6 +226,9 @@ export default function TopUpResult() { // 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 @@ -233,28 +236,30 @@ export default function TopUpResult() { : searchParams.get('success') === 'true'; const isRedirectFailed = redirectStatus ? isFailedStatus(redirectStatus) : false; - // Determine if we can poll (need method + numeric payment_id) + // 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 canPoll = + const canPollById = !!(pendingInfo?.method_id && !isNaN(parsedPaymentId)) && !isRedirectSuccess && !isRedirectFailed; - // Poll payment status + // 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: canPoll && !pollTimedOut, + enabled: canPollById && !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; @@ -265,34 +270,64 @@ export default function TopUpResult() { 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); - refetch(); - }, [setPollTimedOut, refetch]); + if (canPollById) { + refetch(); + } else { + refetchLatest(); + } + }, [canPollById, setPollTimedOut, refetch, refetchLatest]); const handleGoBack = useCallback(() => { clearTopUpPendingInfo(); navigate('/balance', { replace: true }); }, [navigate]); - // Redirect to balance if no data at all + // Redirect to balance if absolutely no data source available useEffect(() => { - if (!pendingInfo && !redirectStatus) { + if (!pendingInfo && !redirectStatus && !methodFromUrl) { navigate('/balance', { replace: true }); } - }, [pendingInfo, redirectStatus, navigate]); + }, [pendingInfo, redirectStatus, methodFromUrl, navigate]); // Determine current visual state - const amountKopeks = paymentStatus?.amount_kopeks ?? pendingInfo?.amount_kopeks ?? null; + const amountKopeks = effectivePayment?.amount_kopeks ?? pendingInfo?.amount_kopeks ?? null; const resolvedPaid = isRedirectSuccess || - paymentStatus?.is_paid || - (paymentStatus && isPaidStatus(paymentStatus.status)); + effectivePayment?.is_paid || + (effectivePayment && isPaidStatus(effectivePayment.status)); const resolvedFailed = - isRedirectFailed || (paymentStatus && isFailedStatus(paymentStatus.status)); + isRedirectFailed || (effectivePayment && isFailedStatus(effectivePayment.status)); // Clean up sessionStorage and invalidate queries when payment resolves useEffect(() => { From 6a9fdac75c3121bfd7d48bd09214a5139b0f77c2 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 9 Mar 2026 05:00:08 +0300 Subject: [PATCH 7/7] refactor: extract shared payment status sets to utility module - Move PAID_STATUSES/FAILED_STATUSES from Balance.tsx and TopUpResult.tsx to src/utils/paymentStatus.ts - Eliminates code duplication between the two pages --- src/pages/Balance.tsx | 27 +++------------------------ src/pages/TopUpResult.tsx | 34 +--------------------------------- src/utils/paymentStatus.ts | 31 +++++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 57 deletions(-) create mode 100644 src/utils/paymentStatus.ts diff --git a/src/pages/Balance.tsx b/src/pages/Balance.tsx index d3ab517..a093431 100644 --- a/src/pages/Balance.tsx +++ b/src/pages/Balance.tsx @@ -13,6 +13,7 @@ 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 }) => ( @@ -64,32 +65,10 @@ export default function Balance() { if (paymentHandledRef.current) return; const paymentStatus = searchParams.get('payment') || searchParams.get('status'); - const paidSet = new Set([ - 'succeeded', - 'success', - 'paid', - 'paid_over', - 'overpaid', - 'completed', - 'confirmed', - 'closed', - ]); - const failedSet = new Set([ - 'fail', - 'failed', - 'error', - 'canceled', - 'cancelled', - 'declined', - 'expired', - 'cancel', - 'system_fail', - 'refund_paid', - ]); const normalised = paymentStatus?.toLowerCase() ?? ''; - const isSuccess = paidSet.has(normalised) || searchParams.get('success') === 'true'; - const isFailed = failedSet.has(normalised); + const isSuccess = isPaidStatus(normalised) || searchParams.get('success') === 'true'; + const isFailed = isFailedStatus(normalised); if (isSuccess) { paymentHandledRef.current = true; diff --git a/src/pages/TopUpResult.tsx b/src/pages/TopUpResult.tsx index ac46a55..783a308 100644 --- a/src/pages/TopUpResult.tsx +++ b/src/pages/TopUpResult.tsx @@ -12,6 +12,7 @@ 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 @@ -177,39 +178,6 @@ function TimeoutState({ onRetry, onGoBack }: { onRetry: () => void; onGoBack: () ); } -// ── Determine paid status from provider-specific status strings ── -const PAID_STATUSES = new Set([ - 'succeeded', - 'success', - 'paid', - 'paid_over', - 'overpaid', - 'completed', - 'confirmed', - 'closed', -]); - -const FAILED_STATUSES = new Set([ - 'fail', - 'failed', - 'error', - 'canceled', - 'cancelled', - 'declined', - 'expired', - 'cancel', - 'system_fail', - 'refund_paid', -]); - -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() { 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()); +}