feat(balance): СБП-привязки в сохранённых методах

This commit is contained in:
Fringg
2026-07-24 06:11:25 +03:00
parent 29a36837c8
commit d938ac2dd9

View File

@@ -1,13 +1,15 @@
import { uiLocale } from '@/utils/uiLocale'; import { uiLocale } from '@/utils/uiLocale';
import { useState } from 'react'; 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 { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { balanceApi } from '../api/balance'; import { balanceApi } from '../api/balance';
import { subscriptionApi } from '../api/subscription';
import { useToast } from '../components/Toast'; import { useToast } from '../components/Toast';
import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog'; import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog';
import type { SbpRecurringInfo, SubscriptionListItem } 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';
@@ -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() { export default function SavedCards() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); 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<SbpBinding[]>((acc, sub, index) => {
const info = sbpQueries[index]?.data;
if (info && info.status !== 'none') {
acc.push({ sub, info });
}
return acc;
}, []);
const [unlinkingSubId, setUnlinkingSubId] = useState<number | null>(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 ( return (
// key: remount the container when loading resolves — stagger orchestration // key: remount the container when loading resolves — stagger orchestration
// runs once on mount, so cards arriving from the API later would otherwise // runs once on mount, so cards arriving from the API later would otherwise
@@ -184,6 +266,62 @@ export default function SavedCards() {
</Card> </Card>
</motion.div> </motion.div>
) : null} ) : 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 && (
<motion.div variants={staggerItem}>
<Card>
<h2 className="mb-3 text-sm font-semibold text-dark-100">
{t('balance.savedCards.sbpSection')}
</h2>
<div className="space-y-3">
{sbpBindings.map(({ sub, info }) => {
const statusKey = sbpStatusLabelKey(info.status);
return (
<div
key={sub.id}
className="flex items-center justify-between rounded-linear border border-dark-700/30 bg-dark-800/30 p-4"
>
<div className="flex items-center gap-3">
<span className="text-xl">🔁</span>
<div>
<div className="font-medium text-dark-100">
{sub.tariff_name || `#${sub.id}`}
</div>
<div className="text-xs text-dark-500">
{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',
}),
})}`
: ''}
</div>
</div>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => handleUnlinkSbp(sub.id)}
loading={unlinkingSubId === sub.id}
className="text-error-400 hover:text-error-300"
>
{t('balance.savedCards.sbpUnlink')}
</Button>
</div>
);
})}
</div>
</Card>
</motion.div>
)}
</motion.div> </motion.div>
); );
} }