mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
11
src/App.tsx
11
src/App.tsx
@@ -29,6 +29,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'));
|
||||
@@ -280,6 +281,16 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/balance/saved-cards"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage>
|
||||
<SavedCards />
|
||||
</LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/balance/top-up"
|
||||
element={
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
PaginatedResponse,
|
||||
PendingPayment,
|
||||
ManualCheckResponse,
|
||||
SavedCardsResponse,
|
||||
} from '../types';
|
||||
|
||||
export const balanceApi = {
|
||||
@@ -129,4 +130,15 @@ export const balanceApi = {
|
||||
);
|
||||
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}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -45,8 +45,8 @@ export interface GiftConfig {
|
||||
export interface GiftPurchaseRequest {
|
||||
tariff_id: number;
|
||||
period_days: number;
|
||||
recipient_type: 'email' | 'telegram';
|
||||
recipient_value: string;
|
||||
recipient_type?: 'email' | 'telegram';
|
||||
recipient_value?: string;
|
||||
gift_message?: string;
|
||||
payment_mode: 'balance' | 'gateway';
|
||||
payment_method?: string;
|
||||
@@ -70,6 +70,8 @@ export type GiftPurchaseStatusValue =
|
||||
export interface GiftPurchaseStatus {
|
||||
status: GiftPurchaseStatusValue;
|
||||
is_gift: boolean;
|
||||
is_code_only: boolean;
|
||||
purchase_token: string | null;
|
||||
recipient_contact_value: string | null;
|
||||
gift_message: string | null;
|
||||
tariff_name: string | null;
|
||||
@@ -86,6 +88,35 @@ export interface PendingGift {
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface SentGift {
|
||||
token: string;
|
||||
tariff_name: string | null;
|
||||
period_days: number;
|
||||
device_limit: number;
|
||||
status: string;
|
||||
gift_recipient_value: string | null;
|
||||
gift_message: string | null;
|
||||
activated_by_username: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface ReceivedGift {
|
||||
token: string;
|
||||
tariff_name: string | null;
|
||||
period_days: number;
|
||||
device_limit: number;
|
||||
status: string;
|
||||
sender_display: string | null;
|
||||
gift_message: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface ActivateGiftResponse {
|
||||
status: string;
|
||||
tariff_name: string | null;
|
||||
period_days: number | null;
|
||||
}
|
||||
|
||||
// API
|
||||
|
||||
export const giftApi = {
|
||||
@@ -108,4 +139,19 @@ export const giftApi = {
|
||||
const { data } = await apiClient.get<PendingGift[]>('/cabinet/gift/pending');
|
||||
return data;
|
||||
},
|
||||
|
||||
getSentGifts: async (): Promise<SentGift[]> => {
|
||||
const { data } = await apiClient.get<SentGift[]>('/cabinet/gift/sent');
|
||||
return data;
|
||||
},
|
||||
|
||||
getReceivedGifts: async (): Promise<ReceivedGift[]> => {
|
||||
const { data } = await apiClient.get<ReceivedGift[]>('/cabinet/gift/received');
|
||||
return data;
|
||||
},
|
||||
|
||||
activateGiftCode: async (code: string): Promise<ActivateGiftResponse> => {
|
||||
const { data } = await apiClient.post<ActivateGiftResponse>('/cabinet/gift/activate', { code });
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useId } from 'react';
|
||||
|
||||
interface PaymentMethodIconProps {
|
||||
method: string;
|
||||
className?: string;
|
||||
@@ -7,6 +9,8 @@ export default function PaymentMethodIcon({
|
||||
method,
|
||||
className = 'h-8 w-8',
|
||||
}: PaymentMethodIconProps) {
|
||||
const uid = useId();
|
||||
|
||||
switch (method) {
|
||||
case 'telegram_stars':
|
||||
return (
|
||||
@@ -171,16 +175,17 @@ export default function PaymentMethodIcon({
|
||||
</svg>
|
||||
);
|
||||
|
||||
case 'kassa_ai':
|
||||
case 'kassa_ai': {
|
||||
const kassaGradId = `${uid}-kassaAi`;
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 40 40">
|
||||
<defs>
|
||||
<linearGradient id="kassaAiGrad" x1="0" y1="0" x2="1" y2="1">
|
||||
<linearGradient id={kassaGradId} x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="#6366F1" />
|
||||
<stop offset="100%" stopColor="#8B5CF6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="20" cy="20" r="20" fill="url(#kassaAiGrad)" />
|
||||
<circle cx="20" cy="20" r="20" fill={`url(#${kassaGradId})`} />
|
||||
<g fill="#fff" fontFamily="Arial,sans-serif" fontWeight="700">
|
||||
<text x="20" y="26" textAnchor="middle" fontSize="15">
|
||||
AI
|
||||
@@ -188,6 +193,27 @@ export default function PaymentMethodIcon({
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
case 'riopay': {
|
||||
const riopayGradId = `${uid}-riopay`;
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 40 40">
|
||||
<defs>
|
||||
<linearGradient id={riopayGradId} x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="#10B981" />
|
||||
<stop offset="100%" stopColor="#059669" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="20" cy="20" r="20" fill={`url(#${riopayGradId})`} />
|
||||
<g fill="#fff" fontFamily="Arial,sans-serif" fontWeight="700">
|
||||
<text x="20" y="26" textAnchor="middle" fontSize="14">
|
||||
RP
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return (
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function PendingGiftCard({ gifts, className }: PendingGiftCardPro
|
||||
|
||||
{/* Activate button */}
|
||||
<Link
|
||||
to={`/buy/success/${gift.token}?activate=1`}
|
||||
to={`/gift?tab=activate&code=${gift.token}`}
|
||||
className="shrink-0 rounded-xl bg-accent-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-400"
|
||||
>
|
||||
{t('gift.pending.activate')}
|
||||
|
||||
@@ -207,79 +207,92 @@ export default function SubscriptionCardExpired({
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2.5">
|
||||
{/* Quick Renew or Top Up button */}
|
||||
{hasBalance ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleQuickRenew}
|
||||
disabled={isRenewing}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-[14px] py-3.5 text-[15px] font-semibold tracking-tight text-white transition-all duration-300 disabled:opacity-50"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #FF3B5C, #FF6B35)',
|
||||
boxShadow: '0 4px 20px rgba(255,59,92,0.2)',
|
||||
}}
|
||||
>
|
||||
{isRenewing ? (
|
||||
<span
|
||||
className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
{/* Quick Renew or Top Up button (hidden for expired trials) */}
|
||||
{!subscription.is_trial && (
|
||||
<>
|
||||
{hasBalance ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleQuickRenew}
|
||||
disabled={isRenewing}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-[14px] py-3.5 text-[15px] font-semibold tracking-tight text-white transition-all duration-300 disabled:opacity-50"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #FF3B5C, #FF6B35)',
|
||||
boxShadow: '0 4px 20px rgba(255,59,92,0.2)',
|
||||
}}
|
||||
>
|
||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
|
||||
</svg>
|
||||
{isRenewing ? (
|
||||
<span
|
||||
className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
|
||||
</svg>
|
||||
)}
|
||||
{isRenewing
|
||||
? t('common.loading')
|
||||
: isDisabledDaily
|
||||
? t('dashboard.suspended.resume')
|
||||
: t('dashboard.expired.quickRenew')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTopUp}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-[14px] py-3.5 text-[15px] font-semibold tracking-tight text-white transition-all duration-300"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #FF3B5C, #FF6B35)',
|
||||
boxShadow: '0 4px 20px rgba(255,59,92,0.2)',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
{t('dashboard.expired.topUp')}
|
||||
</button>
|
||||
)}
|
||||
{isRenewing
|
||||
? t('common.loading')
|
||||
: isDisabledDaily
|
||||
? t('dashboard.suspended.resume')
|
||||
: t('dashboard.expired.quickRenew')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTopUp}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-[14px] py-3.5 text-[15px] font-semibold tracking-tight text-white transition-all duration-300"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #FF3B5C, #FF6B35)',
|
||||
boxShadow: '0 4px 20px rgba(255,59,92,0.2)',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
{t('dashboard.expired.topUp')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Renew (go to purchase page) */}
|
||||
{/* Tariffs (go to purchase page) — full-width for trials */}
|
||||
<Link
|
||||
to="/subscription/purchase"
|
||||
className="flex items-center justify-center rounded-[14px] px-5 py-3.5 text-[15px] font-semibold tracking-tight text-dark-50/50 transition-colors duration-200"
|
||||
style={{
|
||||
background: g.innerBg,
|
||||
border: `1px solid ${g.innerBorder}`,
|
||||
}}
|
||||
className={`flex items-center justify-center rounded-[14px] px-5 py-3.5 text-[15px] font-semibold tracking-tight transition-colors duration-200 ${
|
||||
subscription.is_trial ? 'flex-1 text-white' : 'text-dark-50/50'
|
||||
}`}
|
||||
style={
|
||||
subscription.is_trial
|
||||
? {
|
||||
background: 'linear-gradient(135deg, #FF3B5C, #FF6B35)',
|
||||
boxShadow: '0 4px 20px rgba(255,59,92,0.2)',
|
||||
}
|
||||
: {
|
||||
background: g.innerBg,
|
||||
border: `1px solid ${g.innerBorder}`,
|
||||
}
|
||||
}
|
||||
>
|
||||
{t('dashboard.expired.tariffs')}
|
||||
</Link>
|
||||
|
||||
@@ -33,6 +33,18 @@ export const BackIcon = ({ className }: IconProps) => (
|
||||
</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) => (
|
||||
<svg
|
||||
className={cn('h-5 w-5', className)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { SalesStatsParams } from '../../api/adminSalesStats';
|
||||
import { salesStatsApi } from '../../api/adminSalesStats';
|
||||
import { METHOD_LABELS } from '../../constants/paymentMethods';
|
||||
import { SALES_STATS } from '../../constants/salesStats';
|
||||
import { useCurrency } from '../../hooks/useCurrency';
|
||||
import { StatCard } from '../stats';
|
||||
@@ -16,21 +17,6 @@ interface DepositsTabProps {
|
||||
params: SalesStatsParams;
|
||||
}
|
||||
|
||||
const METHOD_LABELS: Record<string, string> = {
|
||||
telegram_stars: 'Telegram Stars',
|
||||
tribute: 'Tribute',
|
||||
yookassa: 'YooKassa',
|
||||
cryptobot: 'CryptoBot',
|
||||
heleket: 'Heleket',
|
||||
mulenpay: 'Mulenpay',
|
||||
pal24: 'Pal24',
|
||||
wata: 'Wata',
|
||||
platega: 'Platega',
|
||||
cloudpayments: 'CloudPayments',
|
||||
freekassa: 'FreeKassa',
|
||||
kassa_ai: 'Kassa AI',
|
||||
};
|
||||
|
||||
export function DepositsTab({ params }: DepositsTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
|
||||
17
src/constants/paymentMethods.ts
Normal file
17
src/constants/paymentMethods.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export const METHOD_LABELS: Record<string, string> = {
|
||||
telegram_stars: 'Telegram Stars',
|
||||
tribute: 'Tribute',
|
||||
cryptobot: 'CryptoBot',
|
||||
heleket: 'Heleket',
|
||||
yookassa: 'YooKassa',
|
||||
mulenpay: 'MulenPay',
|
||||
pal24: 'PayPalych',
|
||||
platega: 'Platega',
|
||||
wata: 'WATA',
|
||||
freekassa: 'Freekassa',
|
||||
freekassa_sbp: 'Freekassa СБП',
|
||||
freekassa_card: 'Freekassa Карта',
|
||||
cloudpayments: 'CloudPayments',
|
||||
kassa_ai: 'Kassa AI',
|
||||
riopay: 'RioPay',
|
||||
};
|
||||
@@ -675,6 +675,20 @@
|
||||
"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.",
|
||||
"loadError": "Failed to load saved cards. Please try again later."
|
||||
},
|
||||
"paymentReady": "Payment link is ready",
|
||||
"clickToOpenPayment": "Click the button below to open the payment page in a new tab",
|
||||
"openPaymentPage": "Open payment page",
|
||||
@@ -2727,6 +2741,7 @@
|
||||
"devices": "Devices",
|
||||
"actions": "Actions",
|
||||
"extend": "Extend",
|
||||
"shorten": "Deduct days",
|
||||
"changeTariff": "Change tariff",
|
||||
"cancel": "Cancel",
|
||||
"activate": "Activate",
|
||||
@@ -2745,7 +2760,8 @@
|
||||
"trafficAdded": "Traffic package added",
|
||||
"expired": "expired",
|
||||
"daysLeft": "d left",
|
||||
"deviceLimitUpdated": "Device limit updated"
|
||||
"deviceLimitUpdated": "Device limit updated",
|
||||
"invalidDays": "Please enter a valid number of days (greater than 0)"
|
||||
},
|
||||
"balance": {
|
||||
"current": "Current balance",
|
||||
@@ -4201,6 +4217,53 @@
|
||||
},
|
||||
"warning": {
|
||||
"telegram_unresolvable": "Could not verify this Telegram username. The recipient may not receive a notification about the gift."
|
||||
}
|
||||
},
|
||||
"pageTitle": "Gifts",
|
||||
"tabBuy": "Buy",
|
||||
"tabActivate": "Activate",
|
||||
"tabMyGifts": "My Gifts",
|
||||
"selectTariff": "SELECT TARIFF",
|
||||
"selectPeriod": "SUBSCRIPTION PERIOD",
|
||||
"deviceCount": "{{count}} device",
|
||||
"deviceCount_other": "{{count}} devices",
|
||||
"activateTitle": "Enter gift code",
|
||||
"activateDescription": "Enter the gift code you received from a friend",
|
||||
"activateCodePlaceholder": "GIFT-XXXXXXXXXXXX",
|
||||
"activateButton": "Activate gift",
|
||||
"activating": "Activating...",
|
||||
"activateSuccess": "Gift activated!",
|
||||
"activateSuccessDesc": "{{tariff}} — {{days}} days added to your subscription",
|
||||
"activateError": "Failed to activate gift",
|
||||
"activateSelfError": "You cannot activate your own gift",
|
||||
"myGiftsEmpty": "No gifts yet",
|
||||
"myGiftsEmptyDesc": "Buy a gift for a friend or activate a received code",
|
||||
"sentGiftsTitle": "Sent",
|
||||
"activeGiftsTitle": "Active gifts",
|
||||
"activatedGiftsTitle": "Activated",
|
||||
"receivedGiftsTitle": "Received",
|
||||
"statusAvailable": "AVAILABLE",
|
||||
"statusActivated": "ACTIVATED",
|
||||
"statusPending": "PENDING",
|
||||
"statusDelivered": "DELIVERED",
|
||||
"statusFailed": "FAILED",
|
||||
"statusExpired": "EXPIRED",
|
||||
"statusPendingActivation": "PENDING ACTIVATION",
|
||||
"copyCode": "COPY",
|
||||
"codeCopied": "Code copied!",
|
||||
"shareGift": "SHARE",
|
||||
"shareText": "I have a gift for you! Activate it here:",
|
||||
"codeReadyTitle": "Gift code is ready!",
|
||||
"codeLabel": "Gift code",
|
||||
"sharePreview": "Message to share",
|
||||
"activatedBy": "Activated by {{username}}",
|
||||
"sentTo": "Sent to {{recipient}}",
|
||||
"daysShort": "days",
|
||||
"devicesShort": "dev.",
|
||||
"gbShort": "GB",
|
||||
"unlimitedTraffic": "Unlimited",
|
||||
"shareModalActivateVia": "Activate via bot:",
|
||||
"shareModalActivateViaCabinet": "Or via website:",
|
||||
"copyMessage": "Copy message",
|
||||
"shareToastCopied": "Message copied"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,6 +533,26 @@
|
||||
"noPaymentMethods": "روش پرداخت در دسترس نیست",
|
||||
"selectPaymentMethod": "روش پرداخت را انتخاب کنید",
|
||||
"topUpToComplete": "برای تکمیل خرید شارژ کنید",
|
||||
"pendingPayments": {
|
||||
"title": "پرداختهای در انتظار",
|
||||
"pay": "پرداخت",
|
||||
"checkStatus": "بررسی وضعیت",
|
||||
"checking": "درحال بررسی..."
|
||||
},
|
||||
"savedCards": {
|
||||
"title": "کارتهای ذخیره شده",
|
||||
"card": "کارت",
|
||||
"unlink": "حذف",
|
||||
"confirmUnlink": "آیا مطمئن هستید که میخواهید این کارت را حذف کنید؟ شارژ خودکار موجودی دیگر امکانپذیر نخواهد بود.",
|
||||
"unlinkSuccess": "کارت با موفقیت حذف شد",
|
||||
"unlinkError": "خطا در حذف کارت",
|
||||
"linkedAt": "متصل شده {{date}}",
|
||||
"noCards": "کارت به طور خودکار در شارژ بعدی ذخیره میشود",
|
||||
"pageTitle": "کارتهای ذخیره شده",
|
||||
"backToBalance": "بازگشت",
|
||||
"empty": "کارتی ذخیره نشده است. کارت به طور خودکار در شارژ بعدی ذخیره میشود.",
|
||||
"loadError": "خطا در بارگذاری کارتها. لطفاً بعداً دوباره امتحان کنید."
|
||||
},
|
||||
"paymentSuccess": {
|
||||
"title": "پرداخت موفق",
|
||||
"message": "موجودی شما با موفقیت شارژ شد. وجوه اکنون در دسترس است."
|
||||
@@ -2355,6 +2375,7 @@
|
||||
"devices": "دستگاهها",
|
||||
"actions": "عملیات",
|
||||
"extend": "تمدید",
|
||||
"shorten": "کسر روزها",
|
||||
"changeTariff": "تغییر تعرفه",
|
||||
"cancel": "لغو",
|
||||
"activate": "فعالسازی",
|
||||
@@ -2373,7 +2394,8 @@
|
||||
"trafficAdded": "بسته ترافیک اضافه شد",
|
||||
"expired": "منقضی شده",
|
||||
"daysLeft": "روز باقیمانده",
|
||||
"deviceLimitUpdated": "محدودیت دستگاه بهروز شد"
|
||||
"deviceLimitUpdated": "محدودیت دستگاه بهروز شد",
|
||||
"invalidDays": "لطفاً تعداد روز معتبر وارد کنید (بیشتر از ۰)"
|
||||
},
|
||||
"balance": {
|
||||
"current": "موجودی فعلی",
|
||||
|
||||
@@ -703,6 +703,20 @@
|
||||
"checkStatus": "Проверить статус",
|
||||
"checking": "Проверка..."
|
||||
},
|
||||
"savedCards": {
|
||||
"title": "Сохранённые карты",
|
||||
"card": "Карта",
|
||||
"unlink": "Отвязать",
|
||||
"confirmUnlink": "Вы уверены, что хотите отвязать эту карту? Автоматическое пополнение баланса с неё станет невозможным.",
|
||||
"unlinkSuccess": "Карта успешно отвязана",
|
||||
"unlinkError": "Не удалось отвязать карту",
|
||||
"linkedAt": "Привязана {{date}}",
|
||||
"noCards": "Карта будет сохранена автоматически при следующем пополнении",
|
||||
"pageTitle": "Сохранённые карты",
|
||||
"backToBalance": "Назад",
|
||||
"empty": "Нет привязанных карт. Карта будет сохранена автоматически при следующем пополнении.",
|
||||
"loadError": "Не удалось загрузить сохранённые карты. Попробуйте позже."
|
||||
},
|
||||
"paymentReady": "Ссылка на оплату готова",
|
||||
"clickToOpenPayment": "Нажмите кнопку ниже, чтобы открыть страницу оплаты в новой вкладке",
|
||||
"openPaymentPage": "Открыть страницу оплаты",
|
||||
@@ -3248,6 +3262,7 @@
|
||||
"devices": "Устройств",
|
||||
"actions": "Действия",
|
||||
"extend": "Продлить",
|
||||
"shorten": "Списать дни",
|
||||
"changeTariff": "Сменить тариф",
|
||||
"cancel": "Отменить",
|
||||
"activate": "Активировать",
|
||||
@@ -3266,7 +3281,8 @@
|
||||
"trafficAdded": "Пакет трафика добавлен",
|
||||
"expired": "истёк",
|
||||
"daysLeft": "дн. осталось",
|
||||
"deviceLimitUpdated": "Лимит устройств обновлён"
|
||||
"deviceLimitUpdated": "Лимит устройств обновлён",
|
||||
"invalidDays": "Введите корректное количество дней (больше 0)"
|
||||
},
|
||||
"balance": {
|
||||
"current": "Текущий баланс",
|
||||
@@ -4763,6 +4779,58 @@
|
||||
},
|
||||
"warning": {
|
||||
"telegram_unresolvable": "Не удалось проверить этот Telegram-юзернейм. Получатель может не получить уведомление о подарке."
|
||||
}
|
||||
},
|
||||
"pageTitle": "Подарки",
|
||||
"tabBuy": "Купить",
|
||||
"tabActivate": "Активировать",
|
||||
"tabMyGifts": "Мои подарки",
|
||||
"selectTariff": "ВЫБЕРИТЕ ТАРИФ",
|
||||
"selectPeriod": "ПЕРИОД ПОДПИСКИ",
|
||||
"deviceCount": "{{count}} устройств",
|
||||
"deviceCount_one": "{{count}} устройство",
|
||||
"deviceCount_few": "{{count}} устройства",
|
||||
"deviceCount_many": "{{count}} устройств",
|
||||
"activateTitle": "Введите код подарка",
|
||||
"activateDescription": "Введите код подарка, полученный от друга или знакомого",
|
||||
"activateCodePlaceholder": "GIFT-XXXXXXXXXXXX",
|
||||
"activateButton": "Активировать подарок",
|
||||
"activating": "Активация...",
|
||||
"activateSuccess": "Подарок активирован!",
|
||||
"activateSuccessDesc": "{{tariff}} — {{days}} дн. добавлено к вашей подписке",
|
||||
"activateError": "Не удалось активировать подарок",
|
||||
"activateSelfError": "Нельзя активировать свой собственный подарок",
|
||||
"myGiftsEmpty": "У вас пока нет подарков",
|
||||
"myGiftsEmptyDesc": "Купите подарок для друга или активируйте полученный код",
|
||||
"sentGiftsTitle": "Отправленные",
|
||||
"activeGiftsTitle": "Активные подарки",
|
||||
"activatedGiftsTitle": "Активированные",
|
||||
"receivedGiftsTitle": "Полученные",
|
||||
"statusAvailable": "ДОСТУПЕН",
|
||||
"statusActivated": "АКТИВИРОВАН",
|
||||
"statusPending": "ОЖИДАЕТ",
|
||||
"statusDelivered": "ДОСТАВЛЕН",
|
||||
"statusFailed": "ОШИБКА",
|
||||
"statusExpired": "ПРОСРОЧЕН",
|
||||
"statusPendingActivation": "ОЖИДАЕТ АКТИВАЦИИ",
|
||||
"copyCode": "КОПИРОВАТЬ",
|
||||
"codeCopied": "Код скопирован!",
|
||||
"shareGift": "ПОДЕЛИТЬСЯ",
|
||||
"shareText": "У меня есть подарок для тебя! Активируй его здесь:",
|
||||
"codeReadyTitle": "Код подарка готов!",
|
||||
"codeLabel": "Код подарка",
|
||||
"sharePreview": "Сообщение для отправки",
|
||||
"activatedBy": "Активирован пользователем {{username}}",
|
||||
"sentTo": "Отправлен {{recipient}}",
|
||||
"daysShort": "дн.",
|
||||
"devicesShort": "устр.",
|
||||
"devicesShort_one": "устр.",
|
||||
"devicesShort_few": "устр.",
|
||||
"devicesShort_many": "устр.",
|
||||
"gbShort": "ГБ",
|
||||
"unlimitedTraffic": "Безлимит",
|
||||
"shareModalActivateVia": "Активировать через бота:",
|
||||
"shareModalActivateViaCabinet": "Или через сайт:",
|
||||
"copyMessage": "Скопировать сообщение",
|
||||
"shareToastCopied": "Сообщение скопировано"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,6 +533,26 @@
|
||||
"noPaymentMethods": "支付方式不可用",
|
||||
"selectPaymentMethod": "选择支付方式",
|
||||
"topUpToComplete": "充值以完成购买",
|
||||
"pendingPayments": {
|
||||
"title": "待支付",
|
||||
"pay": "支付",
|
||||
"checkStatus": "检查状态",
|
||||
"checking": "检查中..."
|
||||
},
|
||||
"savedCards": {
|
||||
"title": "已保存的银行卡",
|
||||
"card": "银行卡",
|
||||
"unlink": "解绑",
|
||||
"confirmUnlink": "确定要解绑此卡吗?解绑后将无法自动充值余额。",
|
||||
"unlinkSuccess": "银行卡已成功解绑",
|
||||
"unlinkError": "解绑失败",
|
||||
"linkedAt": "绑定于 {{date}}",
|
||||
"noCards": "银行卡将在下次充值时自动保存",
|
||||
"pageTitle": "已保存的银行卡",
|
||||
"backToBalance": "返回",
|
||||
"empty": "暂无已保存的银行卡。银行卡将在下次充值时自动保存。",
|
||||
"loadError": "加载银行卡失败,请稍后重试。"
|
||||
},
|
||||
"paymentSuccess": {
|
||||
"title": "支付成功",
|
||||
"message": "您的余额已成功充值,资金现已可用。"
|
||||
@@ -2354,6 +2374,7 @@
|
||||
"devices": "设备数",
|
||||
"actions": "操作",
|
||||
"extend": "延长",
|
||||
"shorten": "扣除天数",
|
||||
"changeTariff": "更换套餐",
|
||||
"cancel": "取消",
|
||||
"activate": "激活",
|
||||
@@ -2372,7 +2393,8 @@
|
||||
"trafficAdded": "流量包已添加",
|
||||
"expired": "已过期",
|
||||
"daysLeft": "天剩余",
|
||||
"deviceLimitUpdated": "设备限制已更新"
|
||||
"deviceLimitUpdated": "设备限制已更新",
|
||||
"invalidDays": "请输入有效天数(大于0)"
|
||||
},
|
||||
"balance": {
|
||||
"current": "当前余额",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
|
||||
import { METHOD_LABELS } from '../constants/paymentMethods';
|
||||
import type { PromoGroupSimple } from '../types';
|
||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
|
||||
@@ -18,23 +19,6 @@ const BackIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const METHOD_LABELS: Record<string, string> = {
|
||||
telegram_stars: 'Telegram Stars',
|
||||
tribute: 'Tribute',
|
||||
cryptobot: 'CryptoBot',
|
||||
heleket: 'Heleket',
|
||||
yookassa: 'YooKassa',
|
||||
mulenpay: 'MulenPay',
|
||||
pal24: 'PayPalych',
|
||||
platega: 'Platega',
|
||||
wata: 'WATA',
|
||||
freekassa: 'Freekassa',
|
||||
freekassa_sbp: 'Freekassa СБП',
|
||||
freekassa_card: 'Freekassa Карта',
|
||||
cloudpayments: 'CloudPayments',
|
||||
kassa_ai: 'Kassa AI',
|
||||
};
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { giftApi } from '../api/gift';
|
||||
import { Spinner } from '@/components/ui/Spinner';
|
||||
import { AnimatedCheckmark } from '@/components/ui/AnimatedCheckmark';
|
||||
import { AnimatedCrossmark } from '@/components/ui/AnimatedCrossmark';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const MAX_POLL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
@@ -38,6 +39,153 @@ function PendingState() {
|
||||
);
|
||||
}
|
||||
|
||||
function CodeOnlySuccessState({
|
||||
purchaseToken,
|
||||
tariffName,
|
||||
periodDays,
|
||||
}: {
|
||||
purchaseToken: string;
|
||||
tariffName: string | null;
|
||||
periodDays: number | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const shortCode = purchaseToken.slice(0, 12);
|
||||
const giftCode = `GIFT-${shortCode}`;
|
||||
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME as string | undefined;
|
||||
const botLink = botUsername ? `https://t.me/${botUsername}?start=GIFT_${shortCode}` : null;
|
||||
const cabinetLink = `${window.location.origin}/gift?tab=activate&code=${shortCode}`;
|
||||
|
||||
const fullMessage = [
|
||||
t('gift.shareText', 'I have a gift for you! Activate it here:'),
|
||||
'',
|
||||
botLink ? `${t('gift.shareModalActivateVia', 'Activate via bot:')} ${botLink}` : null,
|
||||
`${t('gift.shareModalActivateViaCabinet', 'Or via website:')} ${cabinetLink}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(fullMessage);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex flex-col items-center gap-6 text-center"
|
||||
>
|
||||
<AnimatedCheckmark />
|
||||
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-dark-50">
|
||||
{t('gift.codeReadyTitle', 'Gift code is ready!')}
|
||||
</h1>
|
||||
{tariffName && periodDays !== null && (
|
||||
<p className="mt-1 text-sm text-dark-300">
|
||||
{tariffName} — {periodDays} {t('gift.days', 'days')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gift code display */}
|
||||
<div className="w-full rounded-xl border border-accent-500/20 bg-accent-500/5 p-4">
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wider text-dark-400">
|
||||
{t('gift.codeLabel', 'Gift code')}
|
||||
</p>
|
||||
<p className="select-all font-mono text-lg font-bold text-accent-400">{giftCode}</p>
|
||||
</div>
|
||||
|
||||
{/* Share message preview */}
|
||||
<div className="w-full rounded-xl border border-dark-700/30 bg-dark-800/40 p-4 text-left">
|
||||
<p className="mb-3 text-sm font-medium text-dark-100">
|
||||
{t('gift.shareText', 'I have a gift for you! Activate it here:')}
|
||||
</p>
|
||||
|
||||
{botLink && (
|
||||
<div className="mb-2">
|
||||
<p className="mb-1 text-xs font-medium text-dark-400">
|
||||
{t('gift.shareModalActivateVia', 'Activate via bot:')}
|
||||
</p>
|
||||
<p className="truncate rounded-lg bg-dark-900/60 px-3 py-2 text-sm text-accent-400">
|
||||
{botLink}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-dark-400">
|
||||
{t('gift.shareModalActivateViaCabinet', 'Or via website:')}
|
||||
</p>
|
||||
<p className="truncate rounded-lg bg-dark-900/60 px-3 py-2 text-sm text-accent-400">
|
||||
{cabinetLink}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Copy button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-center gap-2 rounded-xl px-4 py-3.5 text-sm font-bold transition-all duration-200 active:scale-[0.98]',
|
||||
copied
|
||||
? 'bg-success-500/20 text-success-400'
|
||||
: 'bg-accent-500 text-white shadow-lg shadow-accent-500/25 hover:bg-accent-400',
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{t('common.copied', 'Copied!')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
{t('gift.copyMessage', 'Copy message')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/gift?tab=myGifts')}
|
||||
className="w-full rounded-xl border border-dark-700/50 px-6 py-3 text-sm font-medium text-dark-300 transition-colors hover:bg-dark-800/50"
|
||||
>
|
||||
{t('gift.tabMyGifts', 'My Gifts')}
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeliveredState({
|
||||
recipientContact,
|
||||
tariffName,
|
||||
@@ -375,9 +523,12 @@ export default function GiftResult() {
|
||||
// Balance mode: fetch once, no polling
|
||||
if (isBalanceMode) return false;
|
||||
|
||||
const s = query.state.data?.status;
|
||||
const d = query.state.data;
|
||||
const s = d?.status;
|
||||
if (s === 'delivered' || s === 'failed' || s === 'pending_activation' || s === 'expired')
|
||||
return false;
|
||||
// Code-only gifts stay in 'paid' status — stop polling
|
||||
if (s === 'paid' && d?.is_code_only) return false;
|
||||
|
||||
// Check poll timeout
|
||||
if (Date.now() - pollStart.current > MAX_POLL_MS) {
|
||||
@@ -411,6 +562,8 @@ export default function GiftResult() {
|
||||
);
|
||||
}
|
||||
|
||||
const isCodeOnlyPaid =
|
||||
status?.status === 'paid' && status?.is_code_only && status?.purchase_token != null;
|
||||
const isDelivered = status?.status === 'delivered';
|
||||
const isPendingActivation = status?.status === 'pending_activation';
|
||||
const isFailed = status?.status === 'failed' || status?.status === 'expired';
|
||||
@@ -429,6 +582,12 @@ export default function GiftResult() {
|
||||
>
|
||||
{isError ? (
|
||||
<PollErrorState />
|
||||
) : isCodeOnlyPaid ? (
|
||||
<CodeOnlySuccessState
|
||||
purchaseToken={status.purchase_token!}
|
||||
tariffName={status.tariff_name}
|
||||
periodDays={status.period_days}
|
||||
/>
|
||||
) : isDelivered ? (
|
||||
<DeliveredState
|
||||
recipientContact={status.recipient_contact_value}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
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;
|
||||
inviter_bonus_kopeks: number;
|
||||
inviter_bonus_rubles: number;
|
||||
max_commission_payments: number;
|
||||
partner_section_visible?: boolean;
|
||||
}
|
||||
|
||||
@@ -575,6 +576,21 @@ 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;
|
||||
}
|
||||
|
||||
export interface SavedCardsResponse {
|
||||
cards: SavedCard[];
|
||||
recurrent_enabled: boolean;
|
||||
}
|
||||
|
||||
// Ticket notifications types
|
||||
export interface TicketNotification {
|
||||
id: number;
|
||||
|
||||
Reference in New Issue
Block a user