diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5630838..92212a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,26 @@ jobs: lint: uses: ./.github/workflows/lint.yml + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm run test + build: - needs: lint + needs: [lint, test] runs-on: ubuntu-latest steps: - name: Checkout code diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index bc9a053..1bc20f2 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -535,6 +535,15 @@ export const adminUsersApi = { return response.data; }, + // Send direct Telegram message to user via bot (parity with bot's admin action) + sendMessage: async ( + userId: number, + text: string, + ): Promise<{ success: boolean; message: string }> => { + const response = await apiClient.post(`/cabinet/admin/users/${userId}/send-message`, { text }); + return response.data; + }, + // Update restrictions updateRestrictions: async ( userId: number, diff --git a/src/api/promocodes.ts b/src/api/promocodes.ts index 32e56b7..e459f1e 100644 --- a/src/api/promocodes.ts +++ b/src/api/promocodes.ts @@ -7,7 +7,8 @@ export type PromoCodeType = | 'subscription_days' | 'trial_subscription' | 'promo_group' - | 'discount'; + | 'discount' + | 'balance_and_days'; export interface PromoCode { id: number; diff --git a/src/api/referralNetwork.ts b/src/api/referralNetwork.ts index 9751d08..5f9a592 100644 --- a/src/api/referralNetwork.ts +++ b/src/api/referralNetwork.ts @@ -14,6 +14,11 @@ export const referralNetworkApi = { return response.data; }, + getFullGraph: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/referral-network/'); + return response.data; + }, + getScopedGraph: async (selections: ScopeSelection[]): Promise => { const campaignIds = selections.filter((s) => s.type === 'campaign').map((s) => s.id); const partnerIds = selections.filter((s) => s.type === 'partner').map((s) => s.id); diff --git a/src/components/PaymentMethodIcon.tsx b/src/components/PaymentMethodIcon.tsx index 06d37b7..b08e68c 100644 --- a/src/components/PaymentMethodIcon.tsx +++ b/src/components/PaymentMethodIcon.tsx @@ -367,6 +367,28 @@ export default function PaymentMethodIcon({ ); } + case 'cispay': { + const cispayGradId = `${uid}-cispay`; + return ( + + + + + + + + + + + + + + + + + ); + } + case 'donut': { const donutBgGradId = `${uid}-donut-bg`; const donutGlazeGradId = `${uid}-donut-glaze`; diff --git a/src/components/SuccessNotificationModal.tsx b/src/components/SuccessNotificationModal.tsx index 31cc36d..cf71da0 100644 --- a/src/components/SuccessNotificationModal.tsx +++ b/src/components/SuccessNotificationModal.tsx @@ -3,6 +3,7 @@ * Shows prominent success messages for balance top-ups and subscription purchases. */ +import { uiLocale } from '@/utils/uiLocale'; import { useEffect, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; @@ -96,7 +97,7 @@ export default function SuccessNotificationModal() { // Format expiry date const formattedExpiry = data.expiresAt - ? new Date(data.expiresAt).toLocaleDateString(undefined, { + ? new Date(data.expiresAt).toLocaleDateString(uiLocale(), { day: 'numeric', month: 'long', year: 'numeric', diff --git a/src/components/admin/constants.ts b/src/components/admin/constants.ts index 7a1b1a1..207b361 100644 --- a/src/components/admin/constants.ts +++ b/src/components/admin/constants.ts @@ -58,6 +58,7 @@ export const SETTINGS_TREE: SettingsTreeConfig = { { id: 'payments_etoplatezhi', categories: ['ETOPLATEZHI'] }, { id: 'payments_antilopay', categories: ['ANTILOPAY'] }, { id: 'payments_jupiter', categories: ['JUPITER'] }, + { id: 'payments_cispay', categories: ['CISPAY'] }, { id: 'payments_donut', categories: ['DONUT'] }, { id: 'payments_lava', categories: ['LAVA'] }, { id: 'payments_apple_iap', categories: ['APPLE_IAP'] }, diff --git a/src/components/admin/userDetail/InfoTab.tsx b/src/components/admin/userDetail/InfoTab.tsx index 7438ae3..22ecf6e 100644 --- a/src/components/admin/userDetail/InfoTab.tsx +++ b/src/components/admin/userDetail/InfoTab.tsx @@ -1,12 +1,15 @@ +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; +import { useNotify } from '../../../platform/hooks/useNotify'; import { useCurrency } from '../../../hooks/useCurrency'; import { createNumberInputHandler } from '../../../utils/inputHelpers'; -import type { - UserDetailResponse, - UserListItem, - UserPanelInfo, - UserSubscriptionInfo, +import { + adminUsersApi, + type UserDetailResponse, + type UserListItem, + type UserPanelInfo, + type UserSubscriptionInfo, } from '../../../api/adminUsers'; import type { PromoGroup } from '../../../api/promocodes'; import { ServerIcon } from '@/components/icons'; @@ -92,6 +95,12 @@ export function InfoTab(props: InfoTabProps) { const { t } = useTranslation(); const { formatWithCurrency } = useCurrency(); const navigate = useNavigate(); + const notify = useNotify(); + + // «Отправить сообщение» — паритет с бот-кнопкой в карточке юзера + const [sendMsgOpen, setSendMsgOpen] = useState(false); + const [sendMsgText, setSendMsgText] = useState(''); + const [sendMsgLoading, setSendMsgLoading] = useState(false); const { user, hasPermission, @@ -124,6 +133,32 @@ export function InfoTab(props: InfoTabProps) { onFullDeleteUser, } = props; + const handleSendMessage = async () => { + const text = sendMsgText.trim(); + if (!text || sendMsgLoading) return; + setSendMsgLoading(true); + try { + await adminUsersApi.sendMessage(user.id, text); + notify.success(t('admin.users.sendMessage.success'), t('common.success')); + setSendMsgOpen(false); + setSendMsgText(''); + } catch (err) { + const detail = ( + err as { response?: { data?: { detail?: { code?: string; message?: string } | string } } } + )?.response?.data?.detail; + const code = typeof detail === 'object' ? detail?.code : undefined; + const known = ['no_telegram_id', 'forbidden', 'bad_request']; + const message = + code && known.includes(code) + ? t(`admin.users.sendMessage.errors.${code}`) + : (typeof detail === 'object' ? detail?.message : detail) || + t('admin.users.userActions.error'); + notify.error(message, t('common.error')); + } finally { + setSendMsgLoading(false); + } + }; + return (
{/* Status */} @@ -470,6 +505,16 @@ export function InfoTab(props: InfoTabProps) { {t('admin.users.detail.actions.title')}
+ {hasPermission('users:send_message') && ( + + )}
+ + {/* Send message modal */} + {sendMsgOpen && ( +
+
!sendMsgLoading && setSendMsgOpen(false)} + aria-hidden="true" + /> +
+

+ {t('admin.users.sendMessage.title')} +

+