From d938ac2dd9d9f9e27142ed578e17cc7d76ca4531 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 24 Jul 2026 06:11:25 +0300 Subject: [PATCH] =?UTF-8?q?feat(balance):=20=D0=A1=D0=91=D0=9F-=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=B2=D1=8F=D0=B7=D0=BA=D0=B8=20=D0=B2=20=D1=81?= =?UTF-8?q?=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D1=91=D0=BD=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/SavedCards.tsx | 140 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 139 insertions(+), 1 deletion(-) diff --git a/src/pages/SavedCards.tsx b/src/pages/SavedCards.tsx index 6501df0..81fec61 100644 --- a/src/pages/SavedCards.tsx +++ b/src/pages/SavedCards.tsx @@ -1,13 +1,15 @@ import { uiLocale } from '@/utils/uiLocale'; import { useState } from 'react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useQuery, useQueries, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; import { motion } from 'framer-motion'; import { balanceApi } from '../api/balance'; +import { subscriptionApi } from '../api/subscription'; import { useToast } from '../components/Toast'; import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog'; +import type { SbpRecurringInfo, SubscriptionListItem } from '../types'; import { Card } from '@/components/data-display/Card'; import { Button } from '@/components/primitives/Button'; @@ -24,6 +26,25 @@ function formatCardDate(dateStr: string): string { } } +/** Human-readable locale key for an SBP binding status (mirrors Subscription.tsx). */ +function sbpStatusLabelKey(status: string): string | null { + switch (status) { + case 'PENDING': + return 'subscription.sbpRecurring.statusPending'; + case 'ACTIVE': + return 'subscription.sbpRecurring.statusActive'; + case 'PAST_DUE': + return 'subscription.sbpRecurring.statusPastDue'; + default: + return null; + } +} + +interface SbpBinding { + sub: SubscriptionListItem; + info: SbpRecurringInfo; +} + export default function SavedCards() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -73,6 +94,67 @@ export default function SavedCards() { } }; + // ── SBP (Platega) recurring bindings ──────────────────────────────── + // Same query-key convention as the Subscription page (['sbp-recurring', subId]) + // so the caches are shared and don't refetch redundantly when navigating + // between the two pages. + const { data: subscriptionsData } = useQuery({ + queryKey: ['subscriptions-list'], + queryFn: subscriptionApi.getSubscriptions, + }); + const nonTrialSubs = (subscriptionsData?.subscriptions ?? []).filter((sub) => !sub.is_trial); + + const sbpQueries = useQueries({ + queries: nonTrialSubs.map((sub) => ({ + queryKey: ['sbp-recurring', sub.id], + queryFn: () => subscriptionApi.getSbpRecurring(sub.id), + retry: false, + })), + }); + + // No section at all when nothing is bound: either the feature is off + // (every query 403s) or none of the subscriptions has an active binding. + const sbpBindings: SbpBinding[] = nonTrialSubs.reduce((acc, sub, index) => { + const info = sbpQueries[index]?.data; + if (info && info.status !== 'none') { + acc.push({ sub, info }); + } + return acc; + }, []); + + const [unlinkingSubId, setUnlinkingSubId] = useState(null); + const confirmUnlinkSbp = useDestructiveConfirm(); + + const handleUnlinkSbp = async (subId: number) => { + if (unlinkingSubId !== null) return; + const confirmed = await confirmUnlinkSbp( + t('subscription.sbpRecurring.confirmCancel'), + t('subscription.sbpRecurring.cancel'), + ); + if (!confirmed) return; + setUnlinkingSubId(subId); + try { + await subscriptionApi.cancelSbpRecurring(subId); + await queryClient.invalidateQueries({ queryKey: ['sbp-recurring', subId] }); + showToast({ + type: 'success', + title: t('subscription.sbpRecurring.cancelled'), + message: '', + duration: 3000, + }); + } catch (error) { + console.error('Failed to unlink SBP binding:', error); + showToast({ + type: 'error', + title: t('common.error'), + message: '', + duration: 3000, + }); + } finally { + setUnlinkingSubId(null); + } + }; + return ( // key: remount the container when loading resolves — stagger orchestration // runs once on mount, so cards arriving from the API later would otherwise @@ -184,6 +266,62 @@ export default function SavedCards() { ) : null} + + {/* SBP (Platega) recurring bindings — convenience mirror of the + per-subscription block on the Subscription page. Rendered only + when at least one non-trial subscription has an active binding; + hidden entirely when the feature is off or nothing is bound. */} + {sbpBindings.length > 0 && ( + + +

+ {t('balance.savedCards.sbpSection')} +

+
+ {sbpBindings.map(({ sub, info }) => { + const statusKey = sbpStatusLabelKey(info.status); + return ( +
+
+ 🔁 +
+
+ {sub.tariff_name || `#${sub.id}`} +
+
+ {t('balance.savedCards.sbpBinding')} + {statusKey ? ` · ${t(statusKey)}` : ''} + {info.next_charge_at + ? ` · ${t('subscription.sbpRecurring.nextCharge', { + date: new Date(info.next_charge_at).toLocaleDateString(uiLocale(), { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }), + })}` + : ''} +
+
+
+ +
+ ); + })} +
+
+
+ )} ); }