mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
feat(balance): СБП-привязки в сохранённых методах
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user