feat: add useNotify hook for unified notifications

- Created useNotify hook (src/platform/hooks/useNotify.ts):
  - In Telegram Mini App: uses native showPopup
  - In browser: uses beautiful Toast component
  - Methods: notify(), success(), error(), warning(), info()

- AdminTariffs: improved deletion UX
  - Uses useNotify.warning when tariff has subscriptions
  - Uses useDestructiveConfirm for delete confirmation
  - Shows success toast after deletion
  - Removed custom modal dialog

- AdminEmailTemplates: unified notification style
  - Replaced dialog.alert with useNotify.warning
  - Now shows beautiful Toast on mobile browser

- Added deleteSuccess i18n translations (ru, en, zh, fa)
This commit is contained in:
c0mrade
2026-02-02 02:17:19 +03:00
parent 5b30f24e7e
commit 6f4d1ef085
8 changed files with 125 additions and 49 deletions

View File

@@ -1218,6 +1218,7 @@
"cannotDeleteHasSubscriptions": "Cannot delete: has active subscriptions",
"confirmDelete": "Delete tariff?",
"confirmDeleteText": "This action cannot be undone. The tariff will be permanently deleted.",
"deleteSuccess": "Tariff deleted successfully",
"days": "days",
"days_one": "{{count}} day",
"days_other": "{{count}} days",

View File

@@ -992,6 +992,7 @@
"cannotDeleteHasSubscriptions": "حذف ممکن نیست: اشتراک‌های فعال وجود دارد",
"confirmDelete": "تعرفه حذف شود؟",
"confirmDeleteText": "این عمل قابل بازگشت نیست. تعرفه برای همیشه حذف می‌شود.",
"deleteSuccess": "تعرفه با موفقیت حذف شد",
"days": "روز",
"days_other": "{{count}} روز",
"tabs": {

View File

@@ -1728,6 +1728,7 @@
"cannotDeleteHasSubscriptions": "Нельзя удалить: есть активные подписки",
"confirmDelete": "Удалить тариф?",
"confirmDeleteText": "Это действие нельзя отменить. Тариф будет удален навсегда.",
"deleteSuccess": "Тариф успешно удален",
"days": "дней",
"days_one": "{{count}} день",
"days_few": "{{count}} дня",

View File

@@ -1030,6 +1030,7 @@
"cannotDeleteHasSubscriptions": "无法删除:有活跃订阅",
"confirmDelete": "删除套餐?",
"confirmDeleteText": "此操作不可撤销。套餐将被永久删除。",
"deleteSuccess": "套餐删除成功",
"days": "天",
"days_other": "{{count}} 天",
"tabs": {

View File

@@ -9,7 +9,8 @@ import {
EmailTemplateLanguageData,
} from '../api/adminEmailTemplates';
import { AdminBackButton, BackIcon } from '../components/admin';
import { useIsTelegram, usePlatform } from '../platform/hooks/usePlatform';
import { useIsTelegram } from '../platform/hooks/usePlatform';
import { useNotify } from '@/platform';
// Hook to check if on mobile
function useIsMobile() {
@@ -159,7 +160,7 @@ function TemplateEditor({
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { dialog } = usePlatform();
const notify = useNotify();
const isTelegram = useIsTelegram();
const isMobile = useIsMobile();
const isPreviewDisabled = isTelegram || isMobile;
@@ -401,7 +402,7 @@ function TemplateEditor({
<button
onClick={() => {
if (isPreviewDisabled) {
dialog.alert(
notify.warning(
t('admin.emailTemplates.previewDesktopOnly'),
t('admin.emailTemplates.previewNotAvailable'),
);

View File

@@ -1,9 +1,9 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { tariffsApi, TariffListItem } from '../api/tariffs';
import { AdminBackButton } from '../components/admin';
import { useDestructiveConfirm, useNotify } from '@/platform';
// Icons
const PlusIcon = () => (
@@ -48,8 +48,8 @@ export default function AdminTariffs() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
const confirmDelete = useDestructiveConfirm();
const notify = useNotify();
// Queries
const { data: tariffsData, isLoading } = useQuery({
@@ -62,10 +62,27 @@ export default function AdminTariffs() {
mutationFn: tariffsApi.deleteTariff,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-tariffs'] });
setDeleteConfirm(null);
notify.success(t('admin.tariffs.deleteSuccess'));
},
});
const handleDelete = async (tariff: TariffListItem) => {
if (tariff.subscriptions_count > 0) {
notify.warning(t('admin.tariffs.cannotDeleteHasSubscriptions'));
return;
}
const confirmed = await confirmDelete(
t('admin.tariffs.confirmDeleteText'),
t('common.delete'),
t('admin.tariffs.confirmDelete'),
);
if (confirmed) {
deleteMutation.mutate(tariff.id);
}
};
const toggleMutation = useMutation({
mutationFn: tariffsApi.toggleTariff,
onSuccess: () => {
@@ -204,18 +221,9 @@ export default function AdminTariffs() {
{/* Delete */}
<button
onClick={() => setDeleteConfirm(tariff.id)}
className={`rounded-lg p-2 transition-colors ${
tariff.subscriptions_count > 0
? 'cursor-not-allowed bg-dark-700/50 text-dark-500'
: 'bg-dark-700 text-dark-300 hover:bg-error-500/20 hover:text-error-400'
}`}
title={
tariff.subscriptions_count > 0
? t('admin.tariffs.cannotDeleteHasSubscriptions')
: t('admin.tariffs.delete')
}
disabled={tariff.subscriptions_count > 0}
onClick={() => handleDelete(tariff)}
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-error-500/20 hover:text-error-400"
title={t('admin.tariffs.delete')}
>
<TrashIcon />
</button>
@@ -225,36 +233,6 @@ export default function AdminTariffs() {
))}
</div>
)}
{/* Delete Confirmation */}
{deleteConfirm !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setDeleteConfirm(null)}
/>
<div className="relative w-full max-w-sm rounded-xl bg-dark-800 p-6">
<h3 className="mb-2 text-lg font-semibold text-dark-100">
{t('admin.tariffs.confirmDelete')}
</h3>
<p className="mb-6 text-dark-400">{t('admin.tariffs.confirmDeleteText')}</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setDeleteConfirm(null)}
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
>
{t('common.cancel')}
</button>
<button
onClick={() => deleteMutation.mutate(deleteConfirm)}
className="rounded-lg bg-error-500 px-4 py-2 text-white transition-colors hover:bg-error-600"
>
{t('common.delete')}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,92 @@
import { useCallback } from 'react';
import { usePlatform } from '@/platform/hooks/usePlatform';
import { useToast } from '@/components/Toast';
type NotifyType = 'success' | 'error' | 'warning' | 'info';
interface NotifyOptions {
type?: NotifyType;
title?: string;
message: string;
duration?: number;
}
/**
* Universal notification hook.
* - In Telegram Mini App: uses native showPopup
* - In browser: uses Toast component
*
* @example
* ```tsx
* const notify = useNotify();
*
* // Warning notification
* notify.warning('Cannot delete: has active subscriptions');
*
* // Success notification
* notify.success('Tariff created successfully');
*
* // With title
* notify({ type: 'error', title: 'Error', message: 'Something went wrong' });
* ```
*/
export function useNotify() {
const { dialog, capabilities } = usePlatform();
const { showToast } = useToast();
const notify = useCallback(
(options: NotifyOptions | string) => {
const opts: NotifyOptions = typeof options === 'string' ? { message: options } : options;
const { type = 'info', title, message, duration } = opts;
// In Telegram - use native popup for important messages
if (capabilities.hasNativeDialogs) {
dialog.popup({
title: title,
message: message,
buttons: [{ id: 'ok', type: 'ok', text: 'OK' }],
});
return;
}
// In browser - use Toast
showToast({
type,
title,
message,
duration: duration ?? (type === 'error' ? 7000 : 5000),
});
},
[capabilities.hasNativeDialogs, dialog, showToast],
);
// Shorthand methods
const success = useCallback(
(message: string, title?: string) => notify({ type: 'success', message, title }),
[notify],
);
const error = useCallback(
(message: string, title?: string) => notify({ type: 'error', message, title }),
[notify],
);
const warning = useCallback(
(message: string, title?: string) => notify({ type: 'warning', message, title }),
[notify],
);
const info = useCallback(
(message: string, title?: string) => notify({ type: 'info', message, title }),
[notify],
);
return {
notify,
success,
error,
warning,
info,
};
}

View File

@@ -31,3 +31,4 @@ export { useBackButton, useConditionalBackButton } from './hooks/useBackButton';
export { useMainButton, useSimpleMainButton } from './hooks/useMainButton';
export { useHaptic, useHapticClick, useHapticFeedback } from './hooks/useHaptic';
export { useNativeDialog, useDestructiveConfirm, PopupButtons } from './hooks/useNativeDialog';
export { useNotify } from './hooks/useNotify';