diff --git a/eslint.config.js b/eslint.config.js index 3d94809..2cacac2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -39,6 +39,49 @@ export default tseslint.config( 'no-implied-eval': 'error', 'no-new-func': 'error', 'no-script-url': 'error', + // Cross-platform guard: these browser APIs misbehave inside the Telegram WebView. + // Route them through the platform abstraction instead. The platform adapters and + // the clipboard util (the canonical implementations) are exempted below. + 'no-restricted-properties': [ + 'error', + { + object: 'window', + property: 'confirm', + message: + 'Use useNativeDialog().confirm — window.confirm is silently ignored in the Telegram WebView.', + }, + { + object: 'window', + property: 'alert', + message: + 'Use useNativeDialog().alert — window.alert is silently ignored in the Telegram WebView.', + }, + { + object: 'window', + property: 'open', + message: + 'Use usePlatform().openLink / openTelegramLink — window.open is intercepted by the Telegram WebView.', + }, + { + object: 'window', + property: 'prompt', + message: + 'Use usePrompt() (PromptDialogHost) — window.prompt is not supported in the Telegram WebView.', + }, + { + object: 'navigator', + property: 'clipboard', + message: + 'Use copyToClipboard from @/utils/clipboard — it falls back to execCommand when the Clipboard API is unavailable (Telegram WebView).', + }, + ], + }, + }, + { + // Canonical implementations that intentionally call the restricted browser APIs. + files: ['src/platform/**/*.{ts,tsx}', 'src/utils/clipboard.ts'], + rules: { + 'no-restricted-properties': 'off', }, }, eslintConfigPrettier, diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 0285597..ab8206d 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -65,7 +65,7 @@ export class ErrorBoundary extends Component +
⚠️

Something went wrong

diff --git a/src/components/PromoOffersSection.tsx b/src/components/PromoOffersSection.tsx index 63c4d1a..a38cd03 100644 --- a/src/components/PromoOffersSection.tsx +++ b/src/components/PromoOffersSection.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; import { promoApi, PromoOffer } from '../api/promo'; import { ClockIcon, CheckIcon } from './icons'; -import { usePlatform } from '@/platform/hooks/usePlatform'; +import { useDestructiveConfirm } from '@/platform/hooks/useNativeDialog'; // Helper functions const formatTimeLeft = ( @@ -88,7 +88,7 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio const { t } = useTranslation(); const navigate = useNavigate(); const queryClient = useQueryClient(); - const { dialog, capabilities } = usePlatform(); + const confirmDeactivate = useDestructiveConfirm(); const [claimingId, setClaimingId] = useState(null); const [successMessage, setSuccessMessage] = useState(null); const [errorMessage, setErrorMessage] = useState(null); @@ -160,36 +160,19 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio navigate('/subscription/purchase'); }; - const handleDeactivateClick = () => { + const handleDeactivateClick = async () => { setErrorMessage(null); setSuccessMessage(null); - if (capabilities.hasNativeDialogs) { - dialog - .popup({ - title: t('promo.deactivate.confirmTitle'), - message: t('promo.deactivate.confirmDescription', { - percent: activeDiscount?.discount_percent || 0, - }), - buttons: [ - { id: 'cancel', type: 'cancel', text: '' }, - { id: 'confirm', type: 'destructive', text: t('promo.deactivate.confirm') }, - ], - }) - .then((buttonId) => { - if (buttonId === 'confirm') { - deactivateMutation.mutate(); - } - }); - } else { - const confirmed = window.confirm( - t('promo.deactivate.confirmDescription', { - percent: activeDiscount?.discount_percent || 0, - }), - ); - if (confirmed) { - deactivateMutation.mutate(); - } + const confirmed = await confirmDeactivate( + t('promo.deactivate.confirmDescription', { + percent: activeDiscount?.discount_percent || 0, + }), + t('promo.deactivate.confirm'), + t('promo.deactivate.confirmTitle'), + ); + if (confirmed) { + deactivateMutation.mutate(); } }; diff --git a/src/components/PromptDialogHost.tsx b/src/components/PromptDialogHost.tsx new file mode 100644 index 0000000..204943e --- /dev/null +++ b/src/components/PromptDialogHost.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState, type FormEvent } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; +import { useFocusTrap } from '../hooks/useFocusTrap'; +import { usePromptStore } from '../store/promptDialog'; + +/** + * Global host for usePrompt(). Mount once near the app root. + * Renders an accessible text-input dialog (focus-trapped, Esc/Enter, scroll-locked) + * as a cross-platform replacement for window.prompt, which the Telegram WebView ignores. + */ +export function PromptDialogHost() { + const request = usePromptStore((s) => s.request); + const submit = usePromptStore((s) => s.submit); + const cancel = usePromptStore((s) => s.cancel); + const { t } = useTranslation(); + const [value, setValue] = useState(''); + + const open = request !== null; + const dialogRef = useFocusTrap(open, { onEscape: cancel }); + + useEffect(() => { + if (request) setValue(request.initialValue ?? ''); + }, [request]); + + if (!request) return null; + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + const trimmed = value.trim(); + if (!trimmed) { + cancel(); + return; + } + submit(trimmed); + }; + + return createPortal( +
+ , + document.body, + ); +} diff --git a/src/components/SuccessNotificationModal.tsx b/src/components/SuccessNotificationModal.tsx index 1f43437..c9a5c6a 100644 --- a/src/components/SuccessNotificationModal.tsx +++ b/src/components/SuccessNotificationModal.tsx @@ -10,6 +10,7 @@ import { useNavigate } from 'react-router'; import { useSuccessNotification } from '../store/successNotification'; import { useCurrency } from '../hooks/useCurrency'; import { useTelegramSDK } from '../hooks/useTelegramSDK'; +import { useFocusTrap } from '../hooks/useFocusTrap'; import { useHaptic } from '@/platform'; // Icons @@ -93,6 +94,9 @@ export default function SuccessNotificationModal() { hide(); }, [hide]); + // Esc + scroll-lock are handled by the effects below; the trap only manages focus. + const modalRef = useFocusTrap(isOpen, { lockScroll: false }); + // Escape key to close useEffect(() => { if (!isOpen) return; @@ -205,6 +209,11 @@ export default function SuccessNotificationModal() { {/* Modal */}
@@ -224,7 +234,9 @@ export default function SuccessNotificationModal() { className={`flex flex-col items-center bg-gradient-to-br ${gradientClass} px-6 pb-8 pt-10`} >
{icon}
-

{title}

+

+ {title} +

{message &&

{message}

}
diff --git a/src/components/admin/ButtonsTab.tsx b/src/components/admin/ButtonsTab.tsx index d11e6d8..69f0072 100644 --- a/src/components/admin/ButtonsTab.tsx +++ b/src/components/admin/ButtonsTab.tsx @@ -11,6 +11,7 @@ import { } from '../../api/buttonStyles'; import { Toggle } from './Toggle'; import { useNotify } from '../../platform/hooks/useNotify'; +import { useNativeDialog } from '../../platform/hooks/useNativeDialog'; type StyleValue = 'primary' | 'success' | 'danger' | 'default'; @@ -42,6 +43,7 @@ export function ButtonsTab() { const { t } = useTranslation(); const queryClient = useQueryClient(); const notify = useNotify(); + const { confirm: confirmDialog } = useNativeDialog(); const { data: serverStyles } = useQuery({ queryKey: ['button-styles'], @@ -341,8 +343,8 @@ export function ButtonsTab() { {/* Reset */}
diff --git a/src/components/admin/SettingsTableRow.tsx b/src/components/admin/SettingsTableRow.tsx index 0218b8c..a7d1e36 100644 --- a/src/components/admin/SettingsTableRow.tsx +++ b/src/components/admin/SettingsTableRow.tsx @@ -142,7 +142,7 @@ export function SettingsTableRow({
@@ -247,6 +258,7 @@ export function TelegramPreview({ export function EmailPreview({ open, onClose, subject, htmlContent }: EmailPreviewProps) { const { t } = useTranslation(); + const dialogRef = useFocusTrap(open, { onEscape: onClose }); if (!open) return null; const emptyHtml = `

${t('admin.broadcasts.previewEmpty', '— пусто —')}

`; return createPortal( @@ -255,6 +267,11 @@ export function EmailPreview({ open, onClose, subject, htmlContent }: EmailPrevi onClick={onClose} >
e.stopPropagation()} > @@ -269,7 +286,8 @@ export function EmailPreview({ open, onClose, subject, htmlContent }: EmailPrevi
diff --git a/src/components/common/PageLoader.tsx b/src/components/common/PageLoader.tsx index 125f3a9..5ff5bed 100644 --- a/src/components/common/PageLoader.tsx +++ b/src/components/common/PageLoader.tsx @@ -6,7 +6,7 @@ export default function PageLoader({ variant = 'dark' }: PageLoaderProps) { const spinnerColor = variant === 'dark' ? 'border-accent-500' : 'border-blue-500'; return ( -
+
diff --git a/src/components/connection/blocks/BlockButtons.tsx b/src/components/connection/blocks/BlockButtons.tsx index 2ee6c8b..dad4273 100644 --- a/src/components/connection/blocks/BlockButtons.tsx +++ b/src/components/connection/blocks/BlockButtons.tsx @@ -1,6 +1,7 @@ import { useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import type { RemnawaveButtonClient, LocalizedText } from '@/types'; +import { copyToClipboard } from '@/utils/clipboard'; // eslint-disable-next-line no-script-url const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']; @@ -64,16 +65,7 @@ export function BlockButtons({ const [copied, setCopied] = useState(false); const handleCopy = useCallback(async (url: string) => { - try { - await navigator.clipboard.writeText(url); - } catch { - const textarea = document.createElement('textarea'); - textarea.value = url; - document.body.appendChild(textarea); - textarea.select(); - document.execCommand('copy'); - document.body.removeChild(textarea); - } + await copyToClipboard(url); setCopied(true); setTimeout(() => setCopied(false), 2000); }, []); diff --git a/src/components/dashboard/TrafficProgressBar.tsx b/src/components/dashboard/TrafficProgressBar.tsx index a22f773..73cad28 100644 --- a/src/components/dashboard/TrafficProgressBar.tsx +++ b/src/components/dashboard/TrafficProgressBar.tsx @@ -187,7 +187,7 @@ export default function TrafficProgressBar({ {/* Scale labels */} {!compact && limitGb > 0 && (