- Интеграция рекурентов от Юкассы

- Багфикс личного кабинета
This commit is contained in:
firewookie
2026-03-06 09:47:58 +05:00
parent e278fec506
commit 00a013f02b
13 changed files with 274 additions and 6 deletions

View File

@@ -28,6 +28,7 @@ import Dashboard from './pages/Dashboard';
const Subscription = lazy(() => import('./pages/Subscription'));
const SubscriptionPurchase = lazy(() => import('./pages/SubscriptionPurchase'));
const Balance = lazy(() => import('./pages/Balance'));
const SavedCards = lazy(() => import('./pages/SavedCards'));
const Referral = lazy(() => import('./pages/Referral'));
const Support = lazy(() => import('./pages/Support'));
const Profile = lazy(() => import('./pages/Profile'));
@@ -236,6 +237,16 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/balance/saved-cards"
element={
<ProtectedRoute>
<LazyPage>
<SavedCards />
</LazyPage>
</ProtectedRoute>
}
/>
<Route
path="/balance/top-up"
element={

View File

@@ -296,7 +296,8 @@ export interface UpdateSubscriptionRequest {
| 'create'
| 'add_traffic'
| 'remove_traffic'
| 'set_device_limit';
| 'set_device_limit'
| 'shorten';
days?: number;
end_date?: string;
tariff_id?: number;

View File

@@ -6,6 +6,7 @@ import type {
PaginatedResponse,
PendingPayment,
ManualCheckResponse,
SavedCard,
} from '../types';
export const balanceApi = {
@@ -121,4 +122,17 @@ export const balanceApi = {
);
return response.data;
},
// Get saved payment methods (cards) for recurrent payments
getSavedCards: async (): Promise<{ cards: SavedCard[]; recurrent_enabled: boolean }> => {
const response = await apiClient.get<{ cards: SavedCard[]; recurrent_enabled: boolean }>(
'/cabinet/balance/saved-cards',
);
return response.data;
},
// Unlink (delete) a saved payment method
deleteSavedCard: async (id: number): Promise<void> => {
await apiClient.delete(`/cabinet/balance/saved-cards/${id}`);
},
};

View File

@@ -661,6 +661,19 @@
"checkStatus": "Check Status",
"checking": "Checking..."
},
"savedCards": {
"title": "Saved Cards",
"card": "Card",
"unlink": "Unlink",
"confirmUnlink": "Are you sure you want to unlink this card? Automatic balance top-up will no longer be possible.",
"unlinkSuccess": "Card unlinked successfully",
"unlinkError": "Failed to unlink card",
"linkedAt": "Linked {{date}}",
"noCards": "Card will be saved automatically on next top-up",
"pageTitle": "Saved Cards",
"backToBalance": "Back",
"empty": "No saved cards. A card will be saved automatically on your next top-up."
},
"paymentReady": "Payment link is ready",
"clickToOpenPayment": "Click the button below to open the payment page in a new tab",
"openPaymentPage": "Open payment page",
@@ -2654,6 +2667,7 @@
"devices": "Devices",
"actions": "Actions",
"extend": "Extend",
"shorten": "Deduct days",
"changeTariff": "Change tariff",
"cancel": "Cancel",
"activate": "Activate",

View File

@@ -508,6 +508,25 @@
"noPaymentMethods": "روش پرداخت در دسترس نیست",
"selectPaymentMethod": "روش پرداخت را انتخاب کنید",
"topUpToComplete": "برای تکمیل خرید شارژ کنید",
"pendingPayments": {
"title": "پرداخت‌های در انتظار",
"pay": "پرداخت",
"checkStatus": "بررسی وضعیت",
"checking": "درحال بررسی..."
},
"savedCards": {
"title": "کارت‌های ذخیره شده",
"card": "کارت",
"unlink": "حذف",
"confirmUnlink": "آیا مطمئن هستید که می‌خواهید این کارت را حذف کنید؟ شارژ خودکار موجودی دیگر امکان‌پذیر نخواهد بود.",
"unlinkSuccess": "کارت با موفقیت حذف شد",
"unlinkError": "خطا در حذف کارت",
"linkedAt": "متصل شده {{date}}",
"noCards": "کارت به طور خودکار در شارژ بعدی ذخیره می‌شود",
"pageTitle": "کارت‌های ذخیره شده",
"backToBalance": "بازگشت",
"empty": "کارتی ذخیره نشده است. کارت به طور خودکار در شارژ بعدی ذخیره می‌شود."
},
"paymentSuccess": {
"title": "پرداخت موفق",
"message": "موجودی شما با موفقیت شارژ شد. وجوه اکنون در دسترس است."
@@ -2272,6 +2291,7 @@
"devices": "دستگاه‌ها",
"actions": "عملیات",
"extend": "تمدید",
"shorten": "کسر روزها",
"changeTariff": "تغییر تعرفه",
"cancel": "لغو",
"activate": "فعال‌سازی",

View File

@@ -689,6 +689,19 @@
"checkStatus": "Проверить статус",
"checking": "Проверка..."
},
"savedCards": {
"title": "Сохранённые карты",
"card": "Карта",
"unlink": "Отвязать",
"confirmUnlink": "Вы уверены, что хотите отвязать эту карту? Автоматическое пополнение баланса с неё станет невозможным.",
"unlinkSuccess": "Карта успешно отвязана",
"unlinkError": "Не удалось отвязать карту",
"linkedAt": "Привязана {{date}}",
"noCards": "Карта будет сохранена автоматически при следующем пополнении",
"pageTitle": "Сохранённые карты",
"backToBalance": "Назад",
"empty": "Нет привязанных карт. Карта будет сохранена автоматически при следующем пополнении."
},
"paymentReady": "Ссылка на оплату готова",
"clickToOpenPayment": "Нажмите кнопку ниже, чтобы открыть страницу оплаты в новой вкладке",
"openPaymentPage": "Открыть страницу оплаты",
@@ -3179,6 +3192,7 @@
"devices": "Устройств",
"actions": "Действия",
"extend": "Продлить",
"shorten": "Списать дни",
"changeTariff": "Сменить тариф",
"cancel": "Отменить",
"activate": "Активировать",

View File

@@ -508,6 +508,25 @@
"noPaymentMethods": "支付方式不可用",
"selectPaymentMethod": "选择支付方式",
"topUpToComplete": "充值以完成购买",
"pendingPayments": {
"title": "待支付",
"pay": "支付",
"checkStatus": "检查状态",
"checking": "检查中..."
},
"savedCards": {
"title": "已保存的银行卡",
"card": "银行卡",
"unlink": "解绑",
"confirmUnlink": "确定要解绑此卡吗?解绑后将无法自动充值余额。",
"unlinkSuccess": "银行卡已成功解绑",
"unlinkError": "解绑失败",
"linkedAt": "绑定于 {{date}}",
"noCards": "银行卡将在下次充值时自动保存",
"pageTitle": "已保存的银行卡",
"backToBalance": "返回",
"empty": "暂无已保存的银行卡。银行卡将在下次充值时自动保存。"
},
"paymentSuccess": {
"title": "支付成功",
"message": "您的余额已成功充值,资金现已可用。"
@@ -2271,6 +2290,7 @@
"devices": "设备数",
"actions": "操作",
"extend": "延长",
"shorten": "扣除天数",
"changeTariff": "更换套餐",
"cancel": "取消",
"activate": "激活",

View File

@@ -435,7 +435,7 @@ export default function AdminUserDetail() {
const action = overrideAction || subAction;
const data: UpdateSubscriptionRequest = {
action: action as UpdateSubscriptionRequest['action'],
...(action === 'extend' ? { days: toNumber(subDays, 30) } : {}),
...(action === 'extend' || action === 'shorten' ? { days: toNumber(subDays, 30) } : {}),
...(action === 'change_tariff' && selectedTariffId ? { tariff_id: selectedTariffId } : {}),
...(action === 'create'
? {
@@ -1374,6 +1374,9 @@ export default function AdminUserDetail() {
<option value="extend">
{t('admin.users.detail.subscription.extend')}
</option>
<option value="shorten">
{t('admin.users.detail.subscription.shorten')}
</option>
<option value="change_tariff">
{t('admin.users.detail.subscription.changeTariff')}
</option>
@@ -1385,7 +1388,7 @@ export default function AdminUserDetail() {
</option>
</select>
{subAction === 'extend' && (
{(subAction === 'extend' || subAction === 'shorten') && (
<input
type="number"
value={subDays}

View File

@@ -22,6 +22,12 @@ const ChevronDownIcon = ({ className = 'h-5 w-5' }: { className?: string }) => (
</svg>
);
const ChevronRightIcon = ({ className = 'h-5 w-5' }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
);
const WalletIcon = ({ className = 'h-8 w-8' }: { className?: string }) => (
<svg
className={className}
@@ -112,6 +118,11 @@ export default function Balance() {
queryFn: balanceApi.getPaymentMethods,
});
const { data: savedCardsData } = useQuery({
queryKey: ['saved-cards'],
queryFn: balanceApi.getSavedCards,
});
const normalizeType = (type: string) => type?.toUpperCase?.() ?? type;
const getTypeBadge = (type: string) => {
@@ -263,7 +274,7 @@ export default function Balance() {
{/* Payment Methods */}
{paymentMethods && paymentMethods.length > 0 && (
<motion.div variants={staggerItem}>
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }}>
<Card>
<h2 className="mb-4 text-lg font-semibold text-dark-100">
{t('balance.topUpBalance')}
@@ -421,6 +432,21 @@ export default function Balance() {
</AnimatePresence>
</Card>
</motion.div>
{/* Saved Cards Navigation */}
{savedCardsData?.recurrent_enabled && (
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }}>
<Card interactive onClick={() => navigate('/balance/saved-cards')}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-xl">💳</span>
<span className="font-medium text-dark-100">{t('balance.savedCards.title')}</span>
</div>
<ChevronRightIcon className="h-5 w-5 text-dark-400" />
</div>
</Card>
</motion.div>
)}
</motion.div>
);
}

132
src/pages/SavedCards.tsx Normal file
View File

@@ -0,0 +1,132 @@
import { useState } from 'react';
import { useQuery, 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 { useToast } from '../components/Toast';
import { Card } from '@/components/data-display/Card';
import { Button } from '@/components/primitives/Button';
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
const ArrowLeftIcon = ({ className = 'h-5 w-5' }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
</svg>
);
export default function SavedCards() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showToast } = useToast();
const { data: savedCardsData, refetch: refetchSavedCards } = useQuery({
queryKey: ['saved-cards'],
queryFn: balanceApi.getSavedCards,
});
const savedCards = savedCardsData?.cards;
const [deletingCardId, setDeletingCardId] = useState<number | null>(null);
const handleDeleteCard = async (cardId: number) => {
if (!confirm(t('balance.savedCards.confirmUnlink'))) return;
setDeletingCardId(cardId);
try {
await balanceApi.deleteSavedCard(cardId);
await refetchSavedCards();
queryClient.invalidateQueries({ queryKey: ['saved-cards'] });
showToast({
type: 'success',
title: t('balance.savedCards.unlinkSuccess'),
message: '',
duration: 3000,
});
} catch {
showToast({
type: 'error',
title: t('balance.savedCards.unlinkError'),
message: '',
duration: 3000,
});
} finally {
setDeletingCardId(null);
}
};
return (
<motion.div
className="space-y-6"
variants={staggerContainer}
initial="initial"
animate="animate"
>
{/* Header */}
<motion.div variants={staggerItem} className="flex items-center gap-3">
<button
onClick={() => navigate('/balance')}
className="flex h-10 w-10 items-center justify-center rounded-linear border border-dark-700/30 bg-dark-800/50 text-dark-300 transition-colors hover:bg-dark-700/50 hover:text-dark-100"
>
<ArrowLeftIcon className="h-5 w-5" />
</button>
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">
{t('balance.savedCards.pageTitle')}
</h1>
</motion.div>
{/* Cards List */}
{savedCards && savedCards.length > 0 ? (
<motion.div variants={staggerItem}>
<Card>
<div className="space-y-3">
{savedCards.map((card) => (
<div
key={card.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">
{card.title ||
`${card.card_type || t('balance.savedCards.card')} ${card.card_last4 ? `*${card.card_last4}` : ''}`}
</div>
<div className="text-xs text-dark-500">
{t('balance.savedCards.linkedAt', {
date: new Date(card.created_at).toLocaleDateString(),
})}
</div>
</div>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => handleDeleteCard(card.id)}
loading={deletingCardId === card.id}
className="text-error-400 hover:text-error-300"
>
{t('balance.savedCards.unlink')}
</Button>
</div>
))}
</div>
</Card>
</motion.div>
) : savedCards ? (
/* Empty state - only show when data loaded and empty */
<motion.div variants={staggerItem}>
<Card>
<div className="py-12 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-linear-lg bg-dark-800">
<span className="text-3xl">💳</span>
</div>
<div className="text-dark-400">{t('balance.savedCards.empty')}</div>
</div>
</Card>
</motion.div>
) : null}
</motion.div>
);
}

View File

@@ -454,7 +454,8 @@ export default function TopUpAmount() {
{/* Error message */}
{error && (
<motion.div
variants={staggerItem}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center gap-2 rounded-xl border border-error-500/20 bg-error-500/10 p-3"
>
<svg
@@ -477,7 +478,8 @@ export default function TopUpAmount() {
{/* Payment link display - shown when URL is received */}
{paymentUrl && (
<motion.div
variants={staggerItem}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-3 rounded-2xl border border-success-500/20 bg-success-500/10 p-4"
>
<div className="flex items-center gap-2 text-success-400">

View File

@@ -575,6 +575,16 @@ export interface ManualCheckResponse {
new_status: string | null;
}
// Saved payment method (card) for recurrent payments
export interface SavedCard {
id: number;
method_type: string;
card_last4: string | null;
card_type: string | null;
title: string | null;
created_at: string;
}
// Ticket notifications types
export interface TicketNotification {
id: number;

View File

@@ -20,6 +20,7 @@ export default defineConfig({
server: {
port: 5173,
host: true,
allowedHosts: true,
proxy: {
'/api': {
target: 'http://localhost:8080',