mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
Merge remote-tracking branch 'origin/main' into feat/linear-ui-redesign
# Conflicts: # src/pages/AdminUsers.tsx
This commit is contained in:
@@ -476,4 +476,22 @@ export const adminUsersApi = {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/to-panel`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Reset trial
|
||||
resetTrial: async (userId: number): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/reset-trial`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Reset subscription
|
||||
resetSubscription: async (userId: number): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/reset-subscription`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Disable user
|
||||
disableUser: async (userId: number): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/disable`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -322,6 +322,10 @@
|
||||
"title": "Choose a plan to continue",
|
||||
"description": "Your trial period is ending soon. Select a plan to continue using VPN without restrictions."
|
||||
},
|
||||
"expired": {
|
||||
"title": "Subscription expired",
|
||||
"selectTariff": "Your subscription has expired. Choose a plan below to restore access to VPN."
|
||||
},
|
||||
"connection": {
|
||||
"title": "Connect VPN",
|
||||
"selectDevice": "Select your device",
|
||||
@@ -1764,6 +1768,35 @@
|
||||
"confirm": {
|
||||
"block": "Block this user?"
|
||||
},
|
||||
"userActions": {
|
||||
"delete": "Delete user",
|
||||
"resetTrial": "Reset trial",
|
||||
"resetSubscription": "Reset subscription",
|
||||
"disable": "Disable user",
|
||||
"error": "Failed to perform action",
|
||||
"success": {
|
||||
"delete": "User successfully deleted",
|
||||
"resetTrial": "Trial successfully reset",
|
||||
"resetSubscription": "Subscription successfully reset",
|
||||
"disable": "User successfully disabled"
|
||||
},
|
||||
"confirmDelete": {
|
||||
"title": "Delete user?",
|
||||
"message": "Are you sure? This action is irreversible. All user data will be deleted."
|
||||
},
|
||||
"confirmResetTrial": {
|
||||
"title": "Reset trial?",
|
||||
"message": "The user will be able to use the trial period again."
|
||||
},
|
||||
"confirmResetSubscription": {
|
||||
"title": "Reset subscription?",
|
||||
"message": "The user's current subscription will be cancelled."
|
||||
},
|
||||
"confirmDisable": {
|
||||
"title": "Disable user?",
|
||||
"message": "The user will not be able to use the service until reactivated."
|
||||
}
|
||||
},
|
||||
"detail": {
|
||||
"tabs": {
|
||||
"info": "Information",
|
||||
|
||||
@@ -344,6 +344,10 @@
|
||||
"title": "Выберите тариф для продолжения",
|
||||
"description": "Ваш пробный период скоро закончится. Выберите подходящий тариф, чтобы продолжить пользоваться VPN без ограничений."
|
||||
},
|
||||
"expired": {
|
||||
"title": "Подписка истекла",
|
||||
"selectTariff": "Ваша подписка истекла. Выберите тариф ниже, чтобы возобновить доступ к VPN."
|
||||
},
|
||||
"connection": {
|
||||
"title": "Подключить VPN",
|
||||
"selectDevice": "Выберите устройство",
|
||||
@@ -2278,6 +2282,35 @@
|
||||
"confirm": {
|
||||
"block": "Заблокировать пользователя?"
|
||||
},
|
||||
"userActions": {
|
||||
"delete": "Удалить пользователя",
|
||||
"resetTrial": "Сбросить триал",
|
||||
"resetSubscription": "Сбросить подписку",
|
||||
"disable": "Отключить пользователя",
|
||||
"error": "Не удалось выполнить действие",
|
||||
"success": {
|
||||
"delete": "Пользователь успешно удалён",
|
||||
"resetTrial": "Триал успешно сброшен",
|
||||
"resetSubscription": "Подписка успешно сброшена",
|
||||
"disable": "Пользователь успешно отключён"
|
||||
},
|
||||
"confirmDelete": {
|
||||
"title": "Удалить пользователя?",
|
||||
"message": "Вы уверены? Это действие необратимо. Все данные пользователя будут удалены."
|
||||
},
|
||||
"confirmResetTrial": {
|
||||
"title": "Сбросить триал?",
|
||||
"message": "Пользователь сможет снова воспользоваться пробным периодом."
|
||||
},
|
||||
"confirmResetSubscription": {
|
||||
"title": "Сбросить подписку?",
|
||||
"message": "Текущая подписка пользователя будет отменена."
|
||||
},
|
||||
"confirmDisable": {
|
||||
"title": "Отключить пользователя?",
|
||||
"message": "Пользователь не сможет пользоваться сервисом до повторной активации."
|
||||
}
|
||||
},
|
||||
"detail": {
|
||||
"tabs": {
|
||||
"info": "Информация",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { adminUsersApi, type UserListItem, type UsersStatsResponse } from '../api/adminUsers';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
@@ -45,6 +46,204 @@ const TelegramIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DotsVerticalIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 6.75a.75.75 0 110-1.5.75.75 0 010 1.5zM12 12.75a.75.75 0 110-1.5.75.75 0 010 1.5zM12 18.75a.75.75 0 110-1.5.75.75 0 010 1.5z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const TrashIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ArrowPathIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const NoSymbolIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ExclamationTriangleIcon = () => (
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ============ Confirmation Modal ============
|
||||
|
||||
interface ConfirmationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText: string;
|
||||
confirmButtonClass?: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
function ConfirmationModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText,
|
||||
confirmButtonClass = 'bg-rose-500 hover:bg-rose-600',
|
||||
isLoading = false,
|
||||
}: ConfirmationModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative w-full max-w-md rounded-2xl border border-dark-700 bg-dark-800 p-6 shadow-xl">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-rose-500/20 text-rose-400">
|
||||
<ExclamationTriangleIcon />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-dark-100">{title}</h3>
|
||||
</div>
|
||||
<p className="mb-6 text-dark-300">{message}</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="flex-1 rounded-xl border border-dark-600 bg-dark-700 py-2.5 text-dark-200 transition-colors hover:bg-dark-600 disabled:opacity-50"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={isLoading}
|
||||
className={`flex-1 rounded-xl py-2.5 text-white transition-colors disabled:opacity-50 ${confirmButtonClass}`}
|
||||
>
|
||||
{isLoading ? t('common.loading') : confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ User Actions Menu ============
|
||||
|
||||
type UserAction = 'delete' | 'resetTrial' | 'resetSubscription' | 'disable';
|
||||
|
||||
interface UserActionsMenuProps {
|
||||
user: UserListItem;
|
||||
onAction: (action: UserAction, user: UserListItem) => void;
|
||||
}
|
||||
|
||||
function UserActionsMenu({ user, onAction }: UserActionsMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleAction = (action: UserAction) => {
|
||||
setIsOpen(false);
|
||||
onAction(action, user);
|
||||
};
|
||||
|
||||
const actions = [
|
||||
{
|
||||
key: 'resetTrial' as const,
|
||||
label: t('admin.users.userActions.resetTrial'),
|
||||
icon: <ArrowPathIcon />,
|
||||
className: 'text-blue-400 hover:bg-blue-500/10',
|
||||
},
|
||||
{
|
||||
key: 'resetSubscription' as const,
|
||||
label: t('admin.users.userActions.resetSubscription'),
|
||||
icon: <ArrowPathIcon />,
|
||||
className: 'text-amber-400 hover:bg-amber-500/10',
|
||||
},
|
||||
{
|
||||
key: 'disable' as const,
|
||||
label: t('admin.users.userActions.disable'),
|
||||
icon: <NoSymbolIcon />,
|
||||
className: 'text-dark-400 hover:bg-dark-700',
|
||||
},
|
||||
{
|
||||
key: 'delete' as const,
|
||||
label: t('admin.users.userActions.delete'),
|
||||
icon: <TrashIcon />,
|
||||
className: 'text-rose-400 hover:bg-rose-500/10',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||
>
|
||||
<DotsVerticalIcon />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 top-full z-50 mt-1 w-56 rounded-xl border border-dark-700 bg-dark-800 py-1 shadow-xl">
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action.key}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAction(action.key);
|
||||
}}
|
||||
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm transition-colors ${action.className}`}
|
||||
>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Components ============
|
||||
|
||||
interface StatCardProps {
|
||||
@@ -94,10 +293,11 @@ function StatusBadge({ status }: { status: string }) {
|
||||
interface UserRowProps {
|
||||
user: UserListItem;
|
||||
onClick: () => void;
|
||||
onAction: (action: UserAction, user: UserListItem) => void;
|
||||
formatAmount: (rubAmount: number) => string;
|
||||
}
|
||||
|
||||
function UserRow({ user, onClick, formatAmount }: UserRowProps) {
|
||||
function UserRow({ user, onClick, onAction, formatAmount }: UserRowProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
@@ -151,6 +351,9 @@ function UserRow({ user, onClick, formatAmount }: UserRowProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions Menu */}
|
||||
<UserActionsMenu user={user} onAction={onAction} />
|
||||
|
||||
<ChevronRightIcon />
|
||||
</div>
|
||||
);
|
||||
@@ -158,10 +361,17 @@ function UserRow({ user, onClick, formatAmount }: UserRowProps) {
|
||||
|
||||
// ============ Main Page ============
|
||||
|
||||
interface ConfirmModalState {
|
||||
isOpen: boolean;
|
||||
action: UserAction | null;
|
||||
user: UserListItem | null;
|
||||
}
|
||||
|
||||
export default function AdminUsers() {
|
||||
const { t } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [users, setUsers] = useState<UserListItem[]>([]);
|
||||
const [stats, setStats] = useState<UsersStatsResponse | null>(null);
|
||||
@@ -172,6 +382,12 @@ export default function AdminUsers() {
|
||||
const [sortBy, setSortBy] = useState<string>('created_at');
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [confirmModal, setConfirmModal] = useState<ConfirmModalState>({
|
||||
isOpen: false,
|
||||
action: null,
|
||||
user: null,
|
||||
});
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
const limit = 20;
|
||||
|
||||
@@ -218,6 +434,104 @@ export default function AdminUsers() {
|
||||
loadUsers();
|
||||
};
|
||||
|
||||
const handleUserAction = (action: UserAction, user: UserListItem) => {
|
||||
setConfirmModal({ isOpen: true, action, user });
|
||||
};
|
||||
|
||||
const closeConfirmModal = () => {
|
||||
setConfirmModal({ isOpen: false, action: null, user: null });
|
||||
};
|
||||
|
||||
const executeAction = async () => {
|
||||
const { action, user } = confirmModal;
|
||||
if (!action || !user) return;
|
||||
|
||||
setActionLoading(true);
|
||||
try {
|
||||
let result: { success: boolean; message: string };
|
||||
|
||||
switch (action) {
|
||||
case 'delete':
|
||||
result = await adminUsersApi.deleteUser(user.id);
|
||||
break;
|
||||
case 'resetTrial':
|
||||
result = await adminUsersApi.resetTrial(user.id);
|
||||
break;
|
||||
case 'resetSubscription':
|
||||
result = await adminUsersApi.resetSubscription(user.id);
|
||||
break;
|
||||
case 'disable':
|
||||
result = await adminUsersApi.disableUser(user.id);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Unknown action');
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: t('common.success'),
|
||||
message: t(`admin.users.userActions.success.${action}`),
|
||||
});
|
||||
loadUsers();
|
||||
loadStats();
|
||||
} else {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: t('common.error'),
|
||||
message: result.message || t('admin.users.userActions.error'),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Action failed:', error);
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: t('common.error'),
|
||||
message: t('admin.users.userActions.error'),
|
||||
});
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
closeConfirmModal();
|
||||
}
|
||||
};
|
||||
|
||||
const getConfirmModalContent = () => {
|
||||
const { action } = confirmModal;
|
||||
if (!action) return { title: '', message: '', confirmText: '', confirmButtonClass: '' };
|
||||
|
||||
const configs: Record<
|
||||
UserAction,
|
||||
{ title: string; message: string; confirmText: string; confirmButtonClass: string }
|
||||
> = {
|
||||
delete: {
|
||||
title: t('admin.users.userActions.confirmDelete.title'),
|
||||
message: t('admin.users.userActions.confirmDelete.message'),
|
||||
confirmText: t('admin.users.userActions.delete'),
|
||||
confirmButtonClass: 'bg-rose-500 hover:bg-rose-600',
|
||||
},
|
||||
resetTrial: {
|
||||
title: t('admin.users.userActions.confirmResetTrial.title'),
|
||||
message: t('admin.users.userActions.confirmResetTrial.message'),
|
||||
confirmText: t('admin.users.userActions.resetTrial'),
|
||||
confirmButtonClass: 'bg-blue-500 hover:bg-blue-600',
|
||||
},
|
||||
resetSubscription: {
|
||||
title: t('admin.users.userActions.confirmResetSubscription.title'),
|
||||
message: t('admin.users.userActions.confirmResetSubscription.message'),
|
||||
confirmText: t('admin.users.userActions.resetSubscription'),
|
||||
confirmButtonClass: 'bg-amber-500 hover:bg-amber-600',
|
||||
},
|
||||
disable: {
|
||||
title: t('admin.users.userActions.confirmDisable.title'),
|
||||
message: t('admin.users.userActions.confirmDisable.message'),
|
||||
confirmText: t('admin.users.userActions.disable'),
|
||||
confirmButtonClass: 'bg-dark-600 hover:bg-dark-500',
|
||||
},
|
||||
};
|
||||
|
||||
return configs[action];
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
const currentPage = Math.floor(offset / limit) + 1;
|
||||
|
||||
@@ -348,6 +662,7 @@ export default function AdminUsers() {
|
||||
key={user.id}
|
||||
user={user}
|
||||
onClick={() => navigate(`/admin/users/${user.id}`)}
|
||||
onAction={handleUserAction}
|
||||
formatAmount={(amount) => formatWithCurrency(amount)}
|
||||
/>
|
||||
))
|
||||
@@ -385,6 +700,15 @@ export default function AdminUsers() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User action confirmation modal */}
|
||||
<ConfirmationModal
|
||||
isOpen={confirmModal.isOpen}
|
||||
onClose={closeConfirmModal}
|
||||
onConfirm={executeAction}
|
||||
isLoading={actionLoading}
|
||||
{...getConfirmModalContent()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -312,6 +312,28 @@ export default function Subscription() {
|
||||
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
||||
setSwitchTariffId(null);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
// Handle subscription_expired error - redirect to purchase flow
|
||||
if (error instanceof AxiosError) {
|
||||
const detail = error.response?.data?.detail;
|
||||
if (
|
||||
typeof detail === 'object' &&
|
||||
detail?.error_code === 'subscription_expired' &&
|
||||
detail?.use_purchase_flow === true
|
||||
) {
|
||||
// Find the tariff user was trying to switch to and open purchase form
|
||||
const targetTariff = tariffs.find((t) => t.id === switchTariffId);
|
||||
if (targetTariff) {
|
||||
setSwitchTariffId(null);
|
||||
setSelectedTariff(targetTariff);
|
||||
setSelectedTariffPeriod(targetTariff.periods[0] || null);
|
||||
setShowTariffPurchase(true);
|
||||
// Refetch purchase-options to get updated expired status
|
||||
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Tariff purchase mutation
|
||||
@@ -1956,6 +1978,40 @@ export default function Subscription() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expired subscription notice - prompt to purchase new tariff */}
|
||||
{isTariffsMode &&
|
||||
purchaseOptions &&
|
||||
'subscription_is_expired' in purchaseOptions &&
|
||||
purchaseOptions.subscription_is_expired && (
|
||||
<div className="mb-6 rounded-xl border border-error-500/30 bg-gradient-to-r from-error-500/10 to-warning-500/10 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-error-500/20">
|
||||
<svg
|
||||
className="h-5 w-5 text-error-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-error-300">
|
||||
{t('subscription.expired.title')}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-dark-400">
|
||||
{t('subscription.expired.selectTariff')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legacy subscription notice - if user has subscription without tariff */}
|
||||
{subscription && !subscription.is_trial && !subscription.tariff_id && (
|
||||
<div className="mb-6 rounded-xl border border-accent-500/30 bg-accent-500/10 p-4">
|
||||
@@ -2067,6 +2123,27 @@ export default function Subscription() {
|
||||
t('subscription.switchTariff.switch')
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Show error (except subscription_expired which redirects to purchase) */}
|
||||
{switchTariffMutation.isError &&
|
||||
(() => {
|
||||
const detail =
|
||||
switchTariffMutation.error instanceof AxiosError
|
||||
? switchTariffMutation.error.response?.data?.detail
|
||||
: null;
|
||||
// Skip displaying if it's subscription_expired (handled by redirect)
|
||||
if (
|
||||
typeof detail === 'object' &&
|
||||
detail?.error_code === 'subscription_expired'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="mt-3 text-center text-sm text-error-400">
|
||||
{getErrorMessage(switchTariffMutation.error)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
})()
|
||||
@@ -2126,11 +2203,20 @@ export default function Subscription() {
|
||||
.map((tariff) => {
|
||||
const isCurrentTariff =
|
||||
tariff.is_current || tariff.id === subscription?.tariff_id;
|
||||
// Check if subscription is expired from purchaseOptions
|
||||
const isSubscriptionExpired =
|
||||
isTariffsMode &&
|
||||
purchaseOptions &&
|
||||
'subscription_is_expired' in purchaseOptions &&
|
||||
purchaseOptions.subscription_is_expired === true;
|
||||
// canSwitch only if subscription is active (not expired, not trial)
|
||||
const canSwitch =
|
||||
subscription &&
|
||||
subscription.tariff_id &&
|
||||
!isCurrentTariff &&
|
||||
!subscription.is_trial;
|
||||
!subscription.is_trial &&
|
||||
!isSubscriptionExpired &&
|
||||
subscription.is_active;
|
||||
// Если есть подписка БЕЗ tariff_id (классическая) - разрешить выбрать тариф
|
||||
const isLegacySubscription =
|
||||
subscription && !subscription.is_trial && !subscription.tariff_id;
|
||||
|
||||
@@ -300,6 +300,10 @@ export interface TariffsPurchaseOptions {
|
||||
current_tariff_id: number | null;
|
||||
balance_kopeks: number;
|
||||
balance_label: string;
|
||||
// New fields for expired subscription handling
|
||||
subscription_status?: string;
|
||||
subscription_is_expired?: boolean;
|
||||
has_subscription?: boolean;
|
||||
}
|
||||
|
||||
export interface ClassicPurchaseOptions {
|
||||
|
||||
Reference in New Issue
Block a user