mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +00:00
- Интеграция рекурентов от Юкассы
- Багфикс личного кабинета
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
132
src/pages/SavedCards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user