Merge pull request #251 from FireWookie/dev_nikita

Add: Интеграция рекурентов Юкассы + fix: скрытие нулевых бонусов на странице реферальной программы
This commit is contained in:
c0mrade
2026-03-10 14:02:35 +03:00
committed by GitHub
13 changed files with 403 additions and 47 deletions

View File

@@ -430,12 +430,16 @@ export default function AdminUserDetail() {
const handleUpdateSubscription = async (overrideAction?: string) => {
if (!userId) return;
const action = overrideAction || subAction;
if ((action === 'extend' || action === 'shorten') && toNumber(subDays, 0) <= 0) {
notify.error(t('admin.users.detail.subscription.invalidDays'));
return;
}
setActionLoading(true);
try {
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 +1378,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 +1392,7 @@ export default function AdminUserDetail() {
</option>
</select>
{subAction === 'extend' && (
{(subAction === 'extend' || subAction === 'shorten') && (
<input
type="number"
value={subDays}

View File

@@ -12,16 +12,10 @@ import type { PaginatedResponse, Transaction } from '../types';
import { Card } from '@/components/data-display/Card';
import { Button } from '@/components/primitives/Button';
import { ChevronDownIcon, ChevronRightIcon } from '@/components/icons';
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
import { isPaidStatus, isFailedStatus } from '../utils/paymentStatus';
// Icons
const ChevronDownIcon = ({ 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="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
);
const WalletIcon = ({ className = 'h-8 w-8' }: { className?: string }) => (
<svg
className={className}
@@ -100,6 +94,15 @@ export default function Balance() {
queryFn: balanceApi.getPaymentMethods,
});
// Deferred: only fetch saved cards after payment methods loaded to avoid extra request on first render.
// The recurrent_enabled flag is cached for 5 min to prevent refetching on every Balance visit.
const { data: savedCardsData } = useQuery({
queryKey: ['saved-cards'],
queryFn: balanceApi.getSavedCards,
enabled: !!paymentMethods,
staleTime: 5 * 60 * 1000,
});
const normalizeType = (type: string) => type?.toUpperCase?.() ?? type;
const getTypeBadge = (type: string) => {
@@ -410,6 +413,21 @@ export default function Balance() {
</AnimatePresence>
</Card>
</motion.div>
{/* Saved Cards Navigation */}
{savedCardsData?.recurrent_enabled && (
<motion.div variants={staggerItem}>
<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>
);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -164,6 +164,55 @@ export default function Referral() {
},
});
const programTerms = useMemo(() => {
if (!terms) return null;
const showNewUserBonus = terms.first_topup_bonus_kopeks > 0;
const showInviterBonus = terms.inviter_bonus_kopeks > 0;
const cardCount = 2 + (showNewUserBonus ? 1 : 0) + (showInviterBonus ? 1 : 0);
const gridColsMap: Record<number, string> = {
2: 'md:grid-cols-2',
3: 'md:grid-cols-3',
4: 'md:grid-cols-4',
};
const gridCols = gridColsMap[cardCount] ?? 'md:grid-cols-4';
return (
<div className="bento-card">
<h2 className="mb-4 text-lg font-semibold text-dark-100">{t('referral.terms.title')}</h2>
<div className={`grid grid-cols-2 gap-4 ${gridCols}`}>
<div className="rounded-xl bg-dark-800/30 p-3">
<div className="text-sm text-dark-500">{t('referral.terms.commission')}</div>
<div className="mt-1 text-lg font-semibold text-dark-100">
{terms.commission_percent}%
</div>
</div>
<div className="rounded-xl bg-dark-800/30 p-3">
<div className="text-sm text-dark-500">{t('referral.terms.minTopup')}</div>
<div className="mt-1 text-lg font-semibold text-dark-100">
{formatAmount(terms.minimum_topup_rubles)} {currencySymbol}
</div>
</div>
{showNewUserBonus && (
<div className="rounded-xl bg-dark-800/30 p-3">
<div className="text-sm text-dark-500">{t('referral.terms.newUserBonus')}</div>
<div className="mt-1 text-lg font-semibold text-success-400">
{formatPositive(terms.first_topup_bonus_rubles)}
</div>
</div>
)}
{showInviterBonus && (
<div className="rounded-xl bg-dark-800/30 p-3">
<div className="text-sm text-dark-500">{t('referral.terms.inviterBonus')}</div>
<div className="mt-1 text-lg font-semibold text-success-400">
{formatPositive(terms.inviter_bonus_rubles)}
</div>
</div>
)}
</div>
</div>
);
}, [terms, t, formatAmount, formatPositive, currencySymbol]);
const copyLink = async () => {
if (!referralLink) return;
try {
@@ -302,37 +351,7 @@ export default function Referral() {
</div>
{/* Program Terms */}
{terms && (
<div className="bento-card">
<h2 className="mb-4 text-lg font-semibold text-dark-100">{t('referral.terms.title')}</h2>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div className="rounded-xl bg-dark-800/30 p-3">
<div className="text-sm text-dark-500">{t('referral.terms.commission')}</div>
<div className="mt-1 text-lg font-semibold text-dark-100">
{terms.commission_percent}%
</div>
</div>
<div className="rounded-xl bg-dark-800/30 p-3">
<div className="text-sm text-dark-500">{t('referral.terms.minTopup')}</div>
<div className="mt-1 text-lg font-semibold text-dark-100">
{formatAmount(terms.minimum_topup_rubles)} {currencySymbol}
</div>
</div>
<div className="rounded-xl bg-dark-800/30 p-3">
<div className="text-sm text-dark-500">{t('referral.terms.newUserBonus')}</div>
<div className="mt-1 text-lg font-semibold text-success-400">
{formatPositive(terms.first_topup_bonus_rubles)}
</div>
</div>
<div className="rounded-xl bg-dark-800/30 p-3">
<div className="text-sm text-dark-500">{t('referral.terms.inviterBonus')}</div>
<div className="mt-1 text-lg font-semibold text-success-400">
{formatPositive(terms.inviter_bonus_rubles)}
</div>
</div>
</div>
</div>
)}
{programTerms}
{/* Referrals List */}
<div className="bento-card">

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

@@ -0,0 +1,184 @@
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 { useDestructiveConfirm } from '../platform/hooks/useNativeDialog';
import { Card } from '@/components/data-display/Card';
import { Button } from '@/components/primitives/Button';
import { BackIcon } from '@/components/icons';
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
function formatCardDate(dateStr: string): string {
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return dateStr;
return date.toLocaleDateString();
} catch {
return dateStr;
}
}
export default function SavedCards() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showToast } = useToast();
const confirmDelete = useDestructiveConfirm();
const {
data: savedCardsData,
isLoading,
isError,
} = useQuery({
queryKey: ['saved-cards'],
queryFn: balanceApi.getSavedCards,
});
const savedCards = savedCardsData?.cards;
const [deletingCardId, setDeletingCardId] = useState<number | null>(null);
const handleDeleteCard = async (cardId: number) => {
if (deletingCardId !== null) return;
const confirmed = await confirmDelete(
t('balance.savedCards.confirmUnlink'),
t('balance.savedCards.unlink'),
);
if (!confirmed) return;
setDeletingCardId(cardId);
try {
await balanceApi.deleteSavedCard(cardId);
await queryClient.invalidateQueries({ queryKey: ['saved-cards'] });
showToast({
type: 'success',
title: t('balance.savedCards.unlinkSuccess'),
message: '',
duration: 3000,
});
} catch (error) {
console.error('Failed to unlink card:', error);
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"
>
<BackIcon 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>
{/* Loading state */}
{isLoading && (
<motion.div variants={staggerItem}>
<Card>
<div className="space-y-3">
{[1, 2].map((i) => (
<div
key={i}
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">
<div className="h-6 w-6 animate-pulse rounded bg-dark-700" />
<div className="space-y-2">
<div className="h-4 w-32 animate-pulse rounded bg-dark-700" />
<div className="h-3 w-24 animate-pulse rounded bg-dark-700" />
</div>
</div>
<div className="h-8 w-20 animate-pulse rounded bg-dark-700" />
</div>
))}
</div>
</Card>
</motion.div>
)}
{/* Error state */}
{isError && (
<motion.div variants={staggerItem}>
<Card>
<div className="py-12 text-center">
<div className="text-error-400">{t('balance.savedCards.loadError')}</div>
</div>
</Card>
</motion.div>
)}
{/* Cards List */}
{!isLoading && !isError && 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: formatCardDate(card.created_at),
})}
</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>
) : !isLoading && !isError && 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>
);
}