diff --git a/src/App.tsx b/src/App.tsx index 3ce9034..a762d57 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -54,6 +54,7 @@ const AdminCampaignEdit = lazy(() => import('./pages/AdminCampaignEdit')); const AdminUsers = lazy(() => import('./pages/AdminUsers')); const AdminPayments = lazy(() => import('./pages/AdminPayments')); const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods')); +const AdminPaymentMethodEdit = lazy(() => import('./pages/AdminPaymentMethodEdit')); const AdminPromoOffers = lazy(() => import('./pages/AdminPromoOffers')); const AdminPromoOfferTemplateEdit = lazy(() => import('./pages/AdminPromoOfferTemplateEdit')); const AdminPromoOfferSend = lazy(() => import('./pages/AdminPromoOfferSend')); @@ -531,6 +532,16 @@ function App() { } /> + + + + + + } + /> { if (typeof window === 'undefined') return null; - // Use SDK helper to get initData - const initData = getSDKInitData(); + const initData = window.Telegram?.WebApp?.initData; if (initData) { tokenStorage.setTelegramInitData(initData); return initData; @@ -155,6 +153,20 @@ apiClient.interceptors.response.use( // Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала) if (error.response?.status === 401 && !originalRequest._retry) { + // Не обрабатываем 401 для авторизационных endpoints - пусть ошибка дойдет до компонента + const authEndpoints = [ + '/cabinet/auth/email/login', + '/cabinet/auth/telegram', + '/cabinet/auth/telegram/widget', + ]; + const requestUrl = originalRequest.url || ''; + const isAuthEndpoint = authEndpoints.some((endpoint) => requestUrl.includes(endpoint)); + + if (isAuthEndpoint) { + // Пробрасываем ошибку в компонент для показа сообщения пользователю + return Promise.reject(error); + } + originalRequest._retry = true; const newToken = await tokenRefreshManager.refreshAccessToken(); diff --git a/src/pages/AdminPaymentMethodEdit.tsx b/src/pages/AdminPaymentMethodEdit.tsx new file mode 100644 index 0000000..c0dd88d --- /dev/null +++ b/src/pages/AdminPaymentMethodEdit.tsx @@ -0,0 +1,460 @@ +import { useState, useEffect } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { adminPaymentMethodsApi } from '../api/adminPaymentMethods'; +import type { PromoGroupSimple } from '../types'; + +const BackIcon = () => ( + + + +); + +const METHOD_ICONS: Record = { + telegram_stars: '⭐', + tribute: '🎁', + cryptobot: '🪙', + heleket: '⚡', + yookassa: '🏦', + mulenpay: '💳', + pal24: '💸', + platega: '💰', + wata: '💧', + freekassa: '💵', + cloudpayments: '☁️', + kassa_ai: '🏦', +}; + +const METHOD_LABELS: Record = { + telegram_stars: 'Telegram Stars', + tribute: 'Tribute', + cryptobot: 'CryptoBot', + heleket: 'Heleket', + yookassa: 'YooKassa', + mulenpay: 'MulenPay', + pal24: 'PayPalych', + platega: 'Platega', + wata: 'WATA', + freekassa: 'Freekassa', + cloudpayments: 'CloudPayments', + kassa_ai: 'Kassa AI', +}; + +const CheckIcon = () => ( + + + +); + +const SaveIcon = () => ( + + + +); + +export default function AdminPaymentMethodEdit() { + const { t } = useTranslation(); + const { methodId } = useParams<{ methodId: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + // Fetch payment methods + const { data: methods, isLoading } = useQuery({ + queryKey: ['admin-payment-methods'], + queryFn: adminPaymentMethodsApi.getAll, + }); + + // Fetch promo groups + const { data: promoGroups = [] } = useQuery({ + queryKey: ['admin-payment-methods-promo-groups'], + queryFn: adminPaymentMethodsApi.getPromoGroups, + }); + + const config = methods?.find((m) => m.method_id === methodId); + + // Local state for editing + const [isEnabled, setIsEnabled] = useState(false); + const [customName, setCustomName] = useState(''); + const [subOptions, setSubOptions] = useState>({}); + const [minAmount, setMinAmount] = useState(''); + const [maxAmount, setMaxAmount] = useState(''); + const [userTypeFilter, setUserTypeFilter] = useState<'all' | 'telegram' | 'email'>('all'); + const [firstTopupFilter, setFirstTopupFilter] = useState<'any' | 'yes' | 'no'>('any'); + const [promoGroupFilterMode, setPromoGroupFilterMode] = useState<'all' | 'selected'>('all'); + const [selectedPromoGroupIds, setSelectedPromoGroupIds] = useState([]); + + // Initialize state when config loads + useEffect(() => { + if (config) { + setIsEnabled(config.is_enabled); + setCustomName(config.display_name || ''); + setSubOptions(config.sub_options || {}); + setMinAmount(config.min_amount_kopeks?.toString() || ''); + setMaxAmount(config.max_amount_kopeks?.toString() || ''); + setUserTypeFilter(config.user_type_filter); + setFirstTopupFilter(config.first_topup_filter); + setPromoGroupFilterMode(config.promo_group_filter_mode); + setSelectedPromoGroupIds(config.allowed_promo_group_ids); + } + }, [config]); + + // Update method mutation + const updateMethodMutation = useMutation({ + mutationFn: (data: Record) => adminPaymentMethodsApi.update(methodId!, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-payment-methods'] }); + navigate('/admin/payment-methods'); + }, + }); + + const handleSave = () => { + if (!config) return; + + const data: Record = { + is_enabled: isEnabled, + user_type_filter: userTypeFilter, + first_topup_filter: firstTopupFilter, + promo_group_filter_mode: promoGroupFilterMode, + allowed_promo_group_ids: promoGroupFilterMode === 'selected' ? selectedPromoGroupIds : [], + }; + + // Display name + if (customName.trim()) { + data.display_name = customName.trim(); + } else { + data.reset_display_name = true; + } + + // Sub-options + if (config.available_sub_options) { + data.sub_options = subOptions; + } + + // Amounts + if (minAmount.trim()) { + data.min_amount_kopeks = parseInt(minAmount, 10) || null; + } else { + data.reset_min_amount = true; + } + if (maxAmount.trim()) { + data.max_amount_kopeks = parseInt(maxAmount, 10) || null; + } else { + data.reset_max_amount = true; + } + + updateMethodMutation.mutate(data); + }; + + const togglePromoGroup = (id: number) => { + setSelectedPromoGroupIds((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], + ); + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!config) { + return ( +
+
+ + + +

+ {t('admin.paymentMethods.notFound', 'Payment method not found')} +

+
+
+ ); + } + + const displayName = config.display_name || config.default_display_name; + const icon = METHOD_ICONS[config.method_id] || '💳'; + + return ( +
+ {/* Header */} +
+ + + +
+ {icon} +
+
+

{displayName}

+

+ {METHOD_LABELS[config.method_id] || config.method_id} +

+
+
+ + {/* Form */} +
+ {/* Enable toggle */} +
+
+
+ {t('admin.paymentMethods.methodEnabled')} +
+ {!config.is_provider_configured && ( +
+ {t('admin.paymentMethods.providerNotConfigured')} +
+ )} +
+ +
+ + {/* Display name */} +
+ + setCustomName(e.target.value)} + placeholder={config.default_display_name} + className="input" + /> +

+ {t('admin.paymentMethods.displayNameHint')}: {config.default_display_name} +

+
+ + {/* Sub-options */} + {config.available_sub_options && config.available_sub_options.length > 0 && ( +
+ +
+ {config.available_sub_options.map((opt) => { + const enabled = subOptions[opt.id] !== false; + return ( + + ); + })} +
+
+ )} + + {/* Min/Max amounts */} +
+
+ + setMinAmount(e.target.value)} + placeholder={config.default_min_amount_kopeks.toString()} + className="input" + /> +
+
+ + setMaxAmount(e.target.value)} + placeholder={config.default_max_amount_kopeks.toString()} + className="input" + /> +
+
+ + {/* Display conditions */} +
+

+ {t('admin.paymentMethods.conditions')} +

+ + {/* User type filter */} +
+ +
+ {(['all', 'telegram', 'email'] as const).map((val) => ( + + ))} +
+
+ + {/* First topup filter */} +
+ +
+ {(['any', 'yes', 'no'] as const).map((val) => ( + + ))} +
+
+ + {/* Promo groups filter */} +
+ +
+ {(['all', 'selected'] as const).map((val) => ( + + ))} +
+ + {promoGroupFilterMode === 'selected' && ( +
+ {promoGroups.length === 0 ? ( +

+ {t('admin.paymentMethods.noPromoGroups')} +

+ ) : ( + promoGroups.map((group) => { + const selected = selectedPromoGroupIds.includes(group.id); + return ( + + ); + }) + )} +
+ )} +
+
+
+ + {/* Actions */} +
+ + +
+
+ ); +} diff --git a/src/pages/AdminPaymentMethods.tsx b/src/pages/AdminPaymentMethods.tsx index 456f087..c23e30e 100644 --- a/src/pages/AdminPaymentMethods.tsx +++ b/src/pages/AdminPaymentMethods.tsx @@ -1,10 +1,13 @@ import { useState, useCallback, useEffect } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { DndContext, + closestCenter, KeyboardSensor, PointerSensor, + TouchSensor, useSensor, useSensors, type DragEndEvent, @@ -20,11 +23,22 @@ import { } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { adminPaymentMethodsApi } from '../api/adminPaymentMethods'; -import { AdminBackButton } from '../components/admin'; -import type { PaymentMethodConfig, PromoGroupSimple } from '../types'; +import type { PaymentMethodConfig } from '../types'; // ============ Icons ============ +const BackIcon = () => ( + + + +); + const GripIcon = () => ( ( ); -const CloseIcon = () => ( - - - -); - const CheckIcon = () => ( @@ -80,21 +88,6 @@ const METHOD_ICONS: Record = { kassa_ai: '\uD83C\uDFE6', }; -const METHOD_LABELS: Record = { - telegram_stars: 'Telegram Stars', - tribute: 'Tribute', - cryptobot: 'CryptoBot', - heleket: 'Heleket', - yookassa: 'YooKassa', - mulenpay: 'MulenPay', - pal24: 'PayPalych', - platega: 'Platega', - wata: 'WATA', - freekassa: 'Freekassa', - cloudpayments: 'CloudPayments', - kassa_ai: 'Kassa AI', -}; - // ============ Sortable Card ============ interface SortableCardProps { @@ -108,11 +101,11 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) { id: config.method_id, }); - const style: React.CSSProperties = { + const style = { transform: CSS.Transform.toString(transform), transition, zIndex: isDragging ? 50 : undefined, - position: isDragging ? 'relative' : undefined, + opacity: isDragging ? 0.85 : 1, }; const displayName = config.display_name || config.default_display_name; @@ -147,19 +140,19 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
- {/* Drag handle - larger touch target for mobile */} + {/* Drag handle */} -
- -
- {/* Enable toggle */} -
-
-
- {t('admin.paymentMethods.methodEnabled')} -
- {!config.is_provider_configured && ( -
- {t('admin.paymentMethods.providerNotConfigured')} -
- )} -
- -
- - {/* Display name */} -
- - setCustomName(e.target.value)} - placeholder={config.default_display_name} - className="w-full rounded-xl border border-dark-700 bg-dark-900/50 px-4 py-2.5 text-dark-100 transition-colors placeholder:text-dark-500 focus:border-accent-500/50 focus:outline-none" - /> -

- {t('admin.paymentMethods.displayNameHint')}: {config.default_display_name} -

-
- - {/* Sub-options */} - {config.available_sub_options && config.available_sub_options.length > 0 && ( -
- -
- {config.available_sub_options.map((opt) => { - const enabled = subOptions[opt.id] !== false; - return ( - - ); - })} -
-
- )} - - {/* Min/Max amounts */} -
-
- - setMinAmount(e.target.value)} - placeholder={config.default_min_amount_kopeks.toString()} - className="w-full rounded-xl border border-dark-700 bg-dark-900/50 px-4 py-2.5 text-dark-100 transition-colors placeholder:text-dark-500 focus:border-accent-500/50 focus:outline-none" - /> -
-
- - setMaxAmount(e.target.value)} - placeholder={config.default_max_amount_kopeks.toString()} - className="w-full rounded-xl border border-dark-700 bg-dark-900/50 px-4 py-2.5 text-dark-100 transition-colors placeholder:text-dark-500 focus:border-accent-500/50 focus:outline-none" - /> -
-
- - {/* Display conditions */} -
-

- {t('admin.paymentMethods.conditions')} -

- - {/* User type filter */} -
- -
- {(['all', 'telegram', 'email'] as const).map((val) => ( - - ))} -
-
- - {/* First topup filter */} -
- -
- {(['any', 'yes', 'no'] as const).map((val) => ( - - ))} -
-
- - {/* Promo groups filter */} -
- -
- {(['all', 'selected'] as const).map((val) => ( - - ))} -
- - {promoGroupFilterMode === 'selected' && ( -
- {promoGroups.length === 0 ? ( -

- {t('admin.paymentMethods.noPromoGroups')} -

- ) : ( - promoGroups.map((group) => { - const selected = selectedPromoGroupIds.includes(group.id); - return ( - - ); - }) - )} -
- )} -
-
-
- - {/* Footer */} -
- - -
-
- - ); -} - // ============ Toast ============ function Toast({ message, onClose }: { message: string; onClose: () => void }) { @@ -617,10 +234,10 @@ function Toast({ message, onClose }: { message: string; onClose: () => void }) { export default function AdminPaymentMethods() { const { t } = useTranslation(); + const navigate = useNavigate(); const queryClient = useQueryClient(); const [methods, setMethods] = useState([]); - const [selectedMethod, setSelectedMethod] = useState(null); const [orderChanged, setOrderChanged] = useState(false); const [toastMessage, setToastMessage] = useState(null); @@ -630,12 +247,6 @@ export default function AdminPaymentMethods() { queryFn: adminPaymentMethodsApi.getAll, }); - // Fetch promo groups - const { data: promoGroups = [] } = useQuery({ - queryKey: ['admin-payment-methods-promo-groups'], - queryFn: adminPaymentMethodsApi.getPromoGroups, - }); - // Sync fetched data to local state useEffect(() => { if (fetchedMethods && !orderChanged) { @@ -656,23 +267,10 @@ export default function AdminPaymentMethods() { }, }); - // Update method mutation - const updateMethodMutation = useMutation({ - mutationFn: ({ methodId, data }: { methodId: string; data: Record }) => - adminPaymentMethodsApi.update(methodId, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin-payment-methods'] }); - setSelectedMethod(null); - setToastMessage(t('admin.paymentMethods.saved')); - }, - onError: () => { - setToastMessage(t('common.error')); - }, - }); - - // DnD sensors - PointerSensor handles both mouse and touch + // DnD sensors const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); @@ -715,10 +313,6 @@ export default function AdminPaymentMethods() { saveOrderMutation.mutate(methods.map((m) => m.method_id)); }; - const handleSaveMethod = (methodId: string, data: Record) => { - updateMethodMutation.mutate({ methodId, data }); - }; - const handleCloseToast = useCallback(() => setToastMessage(null), []); return ( @@ -726,7 +320,12 @@ export default function AdminPaymentMethods() { {/* Header */}
- + + +

{t('admin.paymentMethods.title')}

{t('admin.paymentMethods.description')}

@@ -763,6 +362,7 @@ export default function AdminPaymentMethods() { ) : methods.length > 0 ? ( setSelectedMethod(config)} + onClick={() => navigate(`/admin/payment-methods/${config.method_id}/edit`)} /> ))}
@@ -792,17 +392,6 @@ export default function AdminPaymentMethods() { )}
- {/* Detail Modal */} - {selectedMethod && ( - setSelectedMethod(null)} - onSave={handleSaveMethod} - isSaving={updateMethodMutation.isPending} - /> - )} - {/* Toast */} {toastMessage && }