mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
Merge pull request #251 from FireWookie/dev_nikita
Add: Интеграция рекурентов Юкассы + fix: скрытие нулевых бонусов на странице реферальной программы
This commit is contained in:
11
src/App.tsx
11
src/App.tsx
@@ -29,6 +29,7 @@ import Dashboard from './pages/Dashboard';
|
|||||||
const Subscription = lazy(() => import('./pages/Subscription'));
|
const Subscription = lazy(() => import('./pages/Subscription'));
|
||||||
const SubscriptionPurchase = lazy(() => import('./pages/SubscriptionPurchase'));
|
const SubscriptionPurchase = lazy(() => import('./pages/SubscriptionPurchase'));
|
||||||
const Balance = lazy(() => import('./pages/Balance'));
|
const Balance = lazy(() => import('./pages/Balance'));
|
||||||
|
const SavedCards = lazy(() => import('./pages/SavedCards'));
|
||||||
const Referral = lazy(() => import('./pages/Referral'));
|
const Referral = lazy(() => import('./pages/Referral'));
|
||||||
const Support = lazy(() => import('./pages/Support'));
|
const Support = lazy(() => import('./pages/Support'));
|
||||||
const Profile = lazy(() => import('./pages/Profile'));
|
const Profile = lazy(() => import('./pages/Profile'));
|
||||||
@@ -280,6 +281,16 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/balance/saved-cards"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<SavedCards />
|
||||||
|
</LazyPage>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/balance/top-up"
|
path="/balance/top-up"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -296,7 +296,8 @@ export interface UpdateSubscriptionRequest {
|
|||||||
| 'create'
|
| 'create'
|
||||||
| 'add_traffic'
|
| 'add_traffic'
|
||||||
| 'remove_traffic'
|
| 'remove_traffic'
|
||||||
| 'set_device_limit';
|
| 'set_device_limit'
|
||||||
|
| 'shorten';
|
||||||
days?: number;
|
days?: number;
|
||||||
end_date?: string;
|
end_date?: string;
|
||||||
tariff_id?: number;
|
tariff_id?: number;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
PendingPayment,
|
PendingPayment,
|
||||||
ManualCheckResponse,
|
ManualCheckResponse,
|
||||||
|
SavedCardsResponse,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
export const balanceApi = {
|
export const balanceApi = {
|
||||||
@@ -129,4 +130,15 @@ export const balanceApi = {
|
|||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Get saved payment methods (cards) for recurrent payments
|
||||||
|
getSavedCards: async (): Promise<SavedCardsResponse> => {
|
||||||
|
const response = await apiClient.get<SavedCardsResponse>('/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}`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,6 +33,18 @@ export const BackIcon = ({ className }: IconProps) => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const ChevronRightIcon = ({ className }: IconProps) => (
|
||||||
|
<svg
|
||||||
|
className={cn('h-5 w-5', 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>
|
||||||
|
);
|
||||||
|
|
||||||
export const MenuIcon = ({ className }: IconProps) => (
|
export const MenuIcon = ({ className }: IconProps) => (
|
||||||
<svg
|
<svg
|
||||||
className={cn('h-5 w-5', className)}
|
className={cn('h-5 w-5', className)}
|
||||||
|
|||||||
@@ -675,6 +675,20 @@
|
|||||||
"checkStatus": "Check Status",
|
"checkStatus": "Check Status",
|
||||||
"checking": "Checking..."
|
"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.",
|
||||||
|
"loadError": "Failed to load saved cards. Please try again later."
|
||||||
|
},
|
||||||
"paymentReady": "Payment link is ready",
|
"paymentReady": "Payment link is ready",
|
||||||
"clickToOpenPayment": "Click the button below to open the payment page in a new tab",
|
"clickToOpenPayment": "Click the button below to open the payment page in a new tab",
|
||||||
"openPaymentPage": "Open payment page",
|
"openPaymentPage": "Open payment page",
|
||||||
@@ -2727,6 +2741,7 @@
|
|||||||
"devices": "Devices",
|
"devices": "Devices",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"extend": "Extend",
|
"extend": "Extend",
|
||||||
|
"shorten": "Deduct days",
|
||||||
"changeTariff": "Change tariff",
|
"changeTariff": "Change tariff",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"activate": "Activate",
|
"activate": "Activate",
|
||||||
@@ -2745,7 +2760,8 @@
|
|||||||
"trafficAdded": "Traffic package added",
|
"trafficAdded": "Traffic package added",
|
||||||
"expired": "expired",
|
"expired": "expired",
|
||||||
"daysLeft": "d left",
|
"daysLeft": "d left",
|
||||||
"deviceLimitUpdated": "Device limit updated"
|
"deviceLimitUpdated": "Device limit updated",
|
||||||
|
"invalidDays": "Please enter a valid number of days (greater than 0)"
|
||||||
},
|
},
|
||||||
"balance": {
|
"balance": {
|
||||||
"current": "Current balance",
|
"current": "Current balance",
|
||||||
|
|||||||
@@ -533,6 +533,26 @@
|
|||||||
"noPaymentMethods": "روش پرداخت در دسترس نیست",
|
"noPaymentMethods": "روش پرداخت در دسترس نیست",
|
||||||
"selectPaymentMethod": "روش پرداخت را انتخاب کنید",
|
"selectPaymentMethod": "روش پرداخت را انتخاب کنید",
|
||||||
"topUpToComplete": "برای تکمیل خرید شارژ کنید",
|
"topUpToComplete": "برای تکمیل خرید شارژ کنید",
|
||||||
|
"pendingPayments": {
|
||||||
|
"title": "پرداختهای در انتظار",
|
||||||
|
"pay": "پرداخت",
|
||||||
|
"checkStatus": "بررسی وضعیت",
|
||||||
|
"checking": "درحال بررسی..."
|
||||||
|
},
|
||||||
|
"savedCards": {
|
||||||
|
"title": "کارتهای ذخیره شده",
|
||||||
|
"card": "کارت",
|
||||||
|
"unlink": "حذف",
|
||||||
|
"confirmUnlink": "آیا مطمئن هستید که میخواهید این کارت را حذف کنید؟ شارژ خودکار موجودی دیگر امکانپذیر نخواهد بود.",
|
||||||
|
"unlinkSuccess": "کارت با موفقیت حذف شد",
|
||||||
|
"unlinkError": "خطا در حذف کارت",
|
||||||
|
"linkedAt": "متصل شده {{date}}",
|
||||||
|
"noCards": "کارت به طور خودکار در شارژ بعدی ذخیره میشود",
|
||||||
|
"pageTitle": "کارتهای ذخیره شده",
|
||||||
|
"backToBalance": "بازگشت",
|
||||||
|
"empty": "کارتی ذخیره نشده است. کارت به طور خودکار در شارژ بعدی ذخیره میشود.",
|
||||||
|
"loadError": "خطا در بارگذاری کارتها. لطفاً بعداً دوباره امتحان کنید."
|
||||||
|
},
|
||||||
"paymentSuccess": {
|
"paymentSuccess": {
|
||||||
"title": "پرداخت موفق",
|
"title": "پرداخت موفق",
|
||||||
"message": "موجودی شما با موفقیت شارژ شد. وجوه اکنون در دسترس است."
|
"message": "موجودی شما با موفقیت شارژ شد. وجوه اکنون در دسترس است."
|
||||||
@@ -2355,6 +2375,7 @@
|
|||||||
"devices": "دستگاهها",
|
"devices": "دستگاهها",
|
||||||
"actions": "عملیات",
|
"actions": "عملیات",
|
||||||
"extend": "تمدید",
|
"extend": "تمدید",
|
||||||
|
"shorten": "کسر روزها",
|
||||||
"changeTariff": "تغییر تعرفه",
|
"changeTariff": "تغییر تعرفه",
|
||||||
"cancel": "لغو",
|
"cancel": "لغو",
|
||||||
"activate": "فعالسازی",
|
"activate": "فعالسازی",
|
||||||
@@ -2373,7 +2394,8 @@
|
|||||||
"trafficAdded": "بسته ترافیک اضافه شد",
|
"trafficAdded": "بسته ترافیک اضافه شد",
|
||||||
"expired": "منقضی شده",
|
"expired": "منقضی شده",
|
||||||
"daysLeft": "روز باقیمانده",
|
"daysLeft": "روز باقیمانده",
|
||||||
"deviceLimitUpdated": "محدودیت دستگاه بهروز شد"
|
"deviceLimitUpdated": "محدودیت دستگاه بهروز شد",
|
||||||
|
"invalidDays": "لطفاً تعداد روز معتبر وارد کنید (بیشتر از ۰)"
|
||||||
},
|
},
|
||||||
"balance": {
|
"balance": {
|
||||||
"current": "موجودی فعلی",
|
"current": "موجودی فعلی",
|
||||||
|
|||||||
@@ -703,6 +703,20 @@
|
|||||||
"checkStatus": "Проверить статус",
|
"checkStatus": "Проверить статус",
|
||||||
"checking": "Проверка..."
|
"checking": "Проверка..."
|
||||||
},
|
},
|
||||||
|
"savedCards": {
|
||||||
|
"title": "Сохранённые карты",
|
||||||
|
"card": "Карта",
|
||||||
|
"unlink": "Отвязать",
|
||||||
|
"confirmUnlink": "Вы уверены, что хотите отвязать эту карту? Автоматическое пополнение баланса с неё станет невозможным.",
|
||||||
|
"unlinkSuccess": "Карта успешно отвязана",
|
||||||
|
"unlinkError": "Не удалось отвязать карту",
|
||||||
|
"linkedAt": "Привязана {{date}}",
|
||||||
|
"noCards": "Карта будет сохранена автоматически при следующем пополнении",
|
||||||
|
"pageTitle": "Сохранённые карты",
|
||||||
|
"backToBalance": "Назад",
|
||||||
|
"empty": "Нет привязанных карт. Карта будет сохранена автоматически при следующем пополнении.",
|
||||||
|
"loadError": "Не удалось загрузить сохранённые карты. Попробуйте позже."
|
||||||
|
},
|
||||||
"paymentReady": "Ссылка на оплату готова",
|
"paymentReady": "Ссылка на оплату готова",
|
||||||
"clickToOpenPayment": "Нажмите кнопку ниже, чтобы открыть страницу оплаты в новой вкладке",
|
"clickToOpenPayment": "Нажмите кнопку ниже, чтобы открыть страницу оплаты в новой вкладке",
|
||||||
"openPaymentPage": "Открыть страницу оплаты",
|
"openPaymentPage": "Открыть страницу оплаты",
|
||||||
@@ -3248,6 +3262,7 @@
|
|||||||
"devices": "Устройств",
|
"devices": "Устройств",
|
||||||
"actions": "Действия",
|
"actions": "Действия",
|
||||||
"extend": "Продлить",
|
"extend": "Продлить",
|
||||||
|
"shorten": "Списать дни",
|
||||||
"changeTariff": "Сменить тариф",
|
"changeTariff": "Сменить тариф",
|
||||||
"cancel": "Отменить",
|
"cancel": "Отменить",
|
||||||
"activate": "Активировать",
|
"activate": "Активировать",
|
||||||
@@ -3266,7 +3281,8 @@
|
|||||||
"trafficAdded": "Пакет трафика добавлен",
|
"trafficAdded": "Пакет трафика добавлен",
|
||||||
"expired": "истёк",
|
"expired": "истёк",
|
||||||
"daysLeft": "дн. осталось",
|
"daysLeft": "дн. осталось",
|
||||||
"deviceLimitUpdated": "Лимит устройств обновлён"
|
"deviceLimitUpdated": "Лимит устройств обновлён",
|
||||||
|
"invalidDays": "Введите корректное количество дней (больше 0)"
|
||||||
},
|
},
|
||||||
"balance": {
|
"balance": {
|
||||||
"current": "Текущий баланс",
|
"current": "Текущий баланс",
|
||||||
|
|||||||
@@ -533,6 +533,26 @@
|
|||||||
"noPaymentMethods": "支付方式不可用",
|
"noPaymentMethods": "支付方式不可用",
|
||||||
"selectPaymentMethod": "选择支付方式",
|
"selectPaymentMethod": "选择支付方式",
|
||||||
"topUpToComplete": "充值以完成购买",
|
"topUpToComplete": "充值以完成购买",
|
||||||
|
"pendingPayments": {
|
||||||
|
"title": "待支付",
|
||||||
|
"pay": "支付",
|
||||||
|
"checkStatus": "检查状态",
|
||||||
|
"checking": "检查中..."
|
||||||
|
},
|
||||||
|
"savedCards": {
|
||||||
|
"title": "已保存的银行卡",
|
||||||
|
"card": "银行卡",
|
||||||
|
"unlink": "解绑",
|
||||||
|
"confirmUnlink": "确定要解绑此卡吗?解绑后将无法自动充值余额。",
|
||||||
|
"unlinkSuccess": "银行卡已成功解绑",
|
||||||
|
"unlinkError": "解绑失败",
|
||||||
|
"linkedAt": "绑定于 {{date}}",
|
||||||
|
"noCards": "银行卡将在下次充值时自动保存",
|
||||||
|
"pageTitle": "已保存的银行卡",
|
||||||
|
"backToBalance": "返回",
|
||||||
|
"empty": "暂无已保存的银行卡。银行卡将在下次充值时自动保存。",
|
||||||
|
"loadError": "加载银行卡失败,请稍后重试。"
|
||||||
|
},
|
||||||
"paymentSuccess": {
|
"paymentSuccess": {
|
||||||
"title": "支付成功",
|
"title": "支付成功",
|
||||||
"message": "您的余额已成功充值,资金现已可用。"
|
"message": "您的余额已成功充值,资金现已可用。"
|
||||||
@@ -2354,6 +2374,7 @@
|
|||||||
"devices": "设备数",
|
"devices": "设备数",
|
||||||
"actions": "操作",
|
"actions": "操作",
|
||||||
"extend": "延长",
|
"extend": "延长",
|
||||||
|
"shorten": "扣除天数",
|
||||||
"changeTariff": "更换套餐",
|
"changeTariff": "更换套餐",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"activate": "激活",
|
"activate": "激活",
|
||||||
@@ -2372,7 +2393,8 @@
|
|||||||
"trafficAdded": "流量包已添加",
|
"trafficAdded": "流量包已添加",
|
||||||
"expired": "已过期",
|
"expired": "已过期",
|
||||||
"daysLeft": "天剩余",
|
"daysLeft": "天剩余",
|
||||||
"deviceLimitUpdated": "设备限制已更新"
|
"deviceLimitUpdated": "设备限制已更新",
|
||||||
|
"invalidDays": "请输入有效天数(大于0)"
|
||||||
},
|
},
|
||||||
"balance": {
|
"balance": {
|
||||||
"current": "当前余额",
|
"current": "当前余额",
|
||||||
|
|||||||
@@ -430,12 +430,16 @@ export default function AdminUserDetail() {
|
|||||||
|
|
||||||
const handleUpdateSubscription = async (overrideAction?: string) => {
|
const handleUpdateSubscription = async (overrideAction?: string) => {
|
||||||
if (!userId) return;
|
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);
|
setActionLoading(true);
|
||||||
try {
|
try {
|
||||||
const action = overrideAction || subAction;
|
|
||||||
const data: UpdateSubscriptionRequest = {
|
const data: UpdateSubscriptionRequest = {
|
||||||
action: action as UpdateSubscriptionRequest['action'],
|
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 === 'change_tariff' && selectedTariffId ? { tariff_id: selectedTariffId } : {}),
|
||||||
...(action === 'create'
|
...(action === 'create'
|
||||||
? {
|
? {
|
||||||
@@ -1374,6 +1378,9 @@ export default function AdminUserDetail() {
|
|||||||
<option value="extend">
|
<option value="extend">
|
||||||
{t('admin.users.detail.subscription.extend')}
|
{t('admin.users.detail.subscription.extend')}
|
||||||
</option>
|
</option>
|
||||||
|
<option value="shorten">
|
||||||
|
{t('admin.users.detail.subscription.shorten')}
|
||||||
|
</option>
|
||||||
<option value="change_tariff">
|
<option value="change_tariff">
|
||||||
{t('admin.users.detail.subscription.changeTariff')}
|
{t('admin.users.detail.subscription.changeTariff')}
|
||||||
</option>
|
</option>
|
||||||
@@ -1385,7 +1392,7 @@ export default function AdminUserDetail() {
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{subAction === 'extend' && (
|
{(subAction === 'extend' || subAction === 'shorten') && (
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={subDays}
|
value={subDays}
|
||||||
|
|||||||
@@ -12,16 +12,10 @@ import type { PaginatedResponse, Transaction } 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';
|
||||||
|
import { ChevronDownIcon, ChevronRightIcon } from '@/components/icons';
|
||||||
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
|
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
|
||||||
import { isPaidStatus, isFailedStatus } from '../utils/paymentStatus';
|
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 }) => (
|
const WalletIcon = ({ className = 'h-8 w-8' }: { className?: string }) => (
|
||||||
<svg
|
<svg
|
||||||
className={className}
|
className={className}
|
||||||
@@ -100,6 +94,15 @@ export default function Balance() {
|
|||||||
queryFn: balanceApi.getPaymentMethods,
|
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 normalizeType = (type: string) => type?.toUpperCase?.() ?? type;
|
||||||
|
|
||||||
const getTypeBadge = (type: string) => {
|
const getTypeBadge = (type: string) => {
|
||||||
@@ -410,6 +413,21 @@ export default function Balance() {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</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>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 () => {
|
const copyLink = async () => {
|
||||||
if (!referralLink) return;
|
if (!referralLink) return;
|
||||||
try {
|
try {
|
||||||
@@ -302,37 +351,7 @@ export default function Referral() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Program Terms */}
|
{/* Program Terms */}
|
||||||
{terms && (
|
{programTerms}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Referrals List */}
|
{/* Referrals List */}
|
||||||
<div className="bento-card">
|
<div className="bento-card">
|
||||||
|
|||||||
184
src/pages/SavedCards.tsx
Normal file
184
src/pages/SavedCards.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -437,6 +437,7 @@ export interface ReferralTerms {
|
|||||||
first_topup_bonus_rubles: number;
|
first_topup_bonus_rubles: number;
|
||||||
inviter_bonus_kopeks: number;
|
inviter_bonus_kopeks: number;
|
||||||
inviter_bonus_rubles: number;
|
inviter_bonus_rubles: number;
|
||||||
|
max_commission_payments: number;
|
||||||
partner_section_visible?: boolean;
|
partner_section_visible?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -575,6 +576,21 @@ export interface ManualCheckResponse {
|
|||||||
new_status: string | null;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SavedCardsResponse {
|
||||||
|
cards: SavedCard[];
|
||||||
|
recurrent_enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// Ticket notifications types
|
// Ticket notifications types
|
||||||
export interface TicketNotification {
|
export interface TicketNotification {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user