From e44a09312edb936104ae9f4641f3e0452f275e79 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 16 May 2026 16:10:32 +0300 Subject: [PATCH 001/105] fix(subscription): forward subscription_id to purchaseTariff to fix renew race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to bedolaga-bot a527df23. Bot's purchase-tariff endpoint now accepts optional subscription_id to resolve the EXACT target row by ID instead of doing a (user_id, tariff_id) re-lookup that races with concurrent panel webhooks (produces 'Тариф уже активен' + refund + no extension). Frontend changes: * subscriptionApi.purchaseTariff() — new optional subscriptionId arg appended after trafficGb. Forwarded as subscription_id in POST body. * SubscriptionPurchase.tsx — forwards URL searchParam subscriptionId (set when user lands here from 'Renew this subscription'). * SubscriptionCardExpired.tsx — passes subscription.id when renewing an expired daily tariff (the .purchaseTariff(tariff_id, 1) call). --- src/api/subscription.ts | 9 +++++++++ src/components/dashboard/SubscriptionCardExpired.tsx | 7 +++++-- src/pages/SubscriptionPurchase.tsx | 12 +++++++++++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/api/subscription.ts b/src/api/subscription.ts index c80bad4..83b103c 100644 --- a/src/api/subscription.ts +++ b/src/api/subscription.ts @@ -398,6 +398,14 @@ export const subscriptionApi = { tariffId: number, periodDays: number, trafficGb?: number, + /** + * Subscription ID being renewed. Pass this when the user clicked + * "Renew" on an existing subscription so the backend can resolve + * the target row by ID instead of doing a (user_id, tariff_id) + * re-lookup that races with concurrent panel webhooks. Omit when + * this is a fresh purchase from the catalog. + */ + subscriptionId?: number, ): Promise<{ success: boolean; message: string; @@ -411,6 +419,7 @@ export const subscriptionApi = { tariff_id: tariffId, period_days: periodDays, traffic_gb: trafficGb, + subscription_id: subscriptionId, }); return response.data; }, diff --git a/src/components/dashboard/SubscriptionCardExpired.tsx b/src/components/dashboard/SubscriptionCardExpired.tsx index 4709ded..5d86ce7 100644 --- a/src/components/dashboard/SubscriptionCardExpired.tsx +++ b/src/components/dashboard/SubscriptionCardExpired.tsx @@ -59,8 +59,11 @@ export default function SubscriptionCardExpired({ // Resume daily subscription via toggle pause endpoint await subscriptionApi.togglePause(subscription.id); } else if (isDaily && subscription.tariff_id) { - // Expired daily tariff — purchase for 1 day - await subscriptionApi.purchaseTariff(subscription.tariff_id, 1); + // Expired daily tariff — purchase for 1 day. Pass subscription.id + // so the backend resolves the EXACT row instead of doing a + // (user_id, tariff_id) re-lookup that races with concurrent + // panel webhooks (would surface as "Тариф уже активен" + refund). + await subscriptionApi.purchaseTariff(subscription.tariff_id, 1, undefined, subscription.id); } else { await subscriptionApi.renewSubscription(30, subscription.id); } diff --git a/src/pages/SubscriptionPurchase.tsx b/src/pages/SubscriptionPurchase.tsx index 8221ece..300cd52 100644 --- a/src/pages/SubscriptionPurchase.tsx +++ b/src/pages/SubscriptionPurchase.tsx @@ -305,7 +305,17 @@ export default function SubscriptionPurchase() { : selectedTariffPeriod?.days || 30; const trafficGb = useCustomTraffic && selectedTariff.custom_traffic_enabled ? customTrafficGb : undefined; - return subscriptionApi.purchaseTariff(selectedTariff.id, days, trafficGb); + // Forward the subscription_id when the user landed here via the + // "Renew this subscription" flow (?subscriptionId=N). The backend + // uses it to resolve the exact target row by ID, avoiding the + // race with concurrent panel webhooks that would otherwise hit + // the partial UNIQUE on uq_subscriptions_user_tariff_active. + return subscriptionApi.purchaseTariff( + selectedTariff.id, + days, + trafficGb, + subscriptionId ?? undefined, + ); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['subscription'] }); From 2bcba3b60f883afc5fbad980fd067b0939a32069 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Fri, 22 May 2026 15:02:56 +0300 Subject: [PATCH 002/105] fix(navigation): make Telegram back button work on deep-link entry Bot deep-links open the Mini App directly on nested routes (/admin, /balance/top-up, /info, /profile, ...) where React Router history has a single entry, so navigate(-1) was a no-op and the native back button looked dead. Fall back to the derived parent route when there is no in-app history (window.history.state.idx === 0). --- src/AppWithNavigator.tsx | 12 +++++++++++- src/utils/navigation.ts | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 src/utils/navigation.ts diff --git a/src/AppWithNavigator.tsx b/src/AppWithNavigator.tsx index 823cce9..f0f20cc 100644 --- a/src/AppWithNavigator.tsx +++ b/src/AppWithNavigator.tsx @@ -15,6 +15,7 @@ import { WebSocketProvider } from './providers/WebSocketProvider'; import { ToastProvider } from './components/Toast'; import { TooltipProvider } from './components/primitives/Tooltip'; import { isInTelegramWebApp } from './hooks/useTelegramSDK'; +import { hasInAppHistory, getFallbackParentPath } from './utils/navigation'; const TWEMOJI_OPTIONS = { className: 'twemoji', folder: 'svg', ext: '.svg' } as const; @@ -30,6 +31,8 @@ function TelegramBackButton() { const navigate = useNavigate(); const navigateRef = useRef(navigate); navigateRef.current = navigate; + const pathnameRef = useRef(location.pathname); + pathnameRef.current = location.pathname; useEffect(() => { const isTopLevel = location.pathname === '' || BOTTOM_NAV_PATHS.includes(location.pathname); @@ -44,7 +47,14 @@ function TelegramBackButton() { // Stable handler — ref prevents re-subscription on every render const handler = useCallback(() => { - navigateRef.current(-1); + // When opened via a bot deep-link directly on a nested route, there is no + // in-app history and navigate(-1) is a no-op — the back button looks dead. + // Fall back to the parent route so it always navigates somewhere sensible. + if (hasInAppHistory()) { + navigateRef.current(-1); + } else { + navigateRef.current(getFallbackParentPath(pathnameRef.current), { replace: true }); + } }, []); useEffect(() => { diff --git a/src/utils/navigation.ts b/src/utils/navigation.ts new file mode 100644 index 0000000..6af9455 --- /dev/null +++ b/src/utils/navigation.ts @@ -0,0 +1,38 @@ +/** + * Navigation helpers for back-button behavior. + * + * The Telegram Mini App (and any deep-link entry) can be opened directly on a + * nested route — e.g. a bot button that opens `/admin` or `/balance/top-up`. + * In that case React Router's history stack holds a single entry, so a plain + * `navigate(-1)` has nothing to go back to and the back button appears dead. + * These helpers let callers detect that situation and fall back to a sensible + * parent route instead. + */ + +/** + * React Router stores the current position in the history stack on + * `window.history.state.idx`. The first/entry record is `0`. When `idx === 0` + * there is no in-app history to go back to (the page is the deep-link entry). + */ +export function hasInAppHistory(): boolean { + const idx = (window.history.state as { idx?: number } | null)?.idx ?? 0; + return idx > 0; +} + +/** + * Derive a parent route by dropping the last path segment. + * + * /admin/users/123 → /admin/users + * /balance/top-up → /balance + * /info → / + * / → / + * + * Used as the back-button target when there is no in-app history. If the + * derived path is not a real route the app's catch-all redirects to `/`, + * so the user is never left stuck. + */ +export function getFallbackParentPath(pathname: string): string { + const segments = pathname.replace(/\/+$/, '').split('/').filter(Boolean); + const parent = segments.slice(0, -1); + return parent.length ? '/' + parent.join('/') : '/'; +} From 8e0b63bac893485752a5837f2f82599dcf752702 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 25 May 2026 23:16:07 +0300 Subject: [PATCH 003/105] feat(a11y): cross-platform hardening + modal focus-trap from impeccable audit Responsive / viewport: - add .min-h-viewport / .h-viewport utilities (100dvh + Telegram --tg-viewport-stable-height) - migrate min-h-screen / h-screen / 100vh across 18 files - reveal hover-only controls on focus-within + touch (desktop nav, admin settings) - grid breakpoints (TopUpAmount, Contests), larger touch targets, nav aria-labels Platform routing (Telegram WebView correctness): - clipboard -> copyToClipboard util with execCommand fallback (10 files) - window.open -> openTelegramLink / openLink (Profile, Referral, ChannelSubscriptionScreen) - window.confirm -> useNativeDialog (admin pages); PromoOffersSection -> useDestructiveConfirm - window.prompt -> usePrompt / PromptDialogHost (TipTap link editors) - ESLint no-restricted-properties guard against regressions (adapters/clipboard util exempt) Modal accessibility: - new useFocusTrap hook (focus trap, Esc, scroll lock, focus restore) - role=dialog / aria-modal / focus-trap: Polls, SuccessNotificationModal, AdminPolicies, AdminPromoGroups, AdminCampaigns, BroadcastPreview (Telegram + Email) - new global usePrompt store + PromptDialogHost --- eslint.config.js | 43 +++++++ src/components/ErrorBoundary.tsx | 2 +- src/components/PromoOffersSection.tsx | 41 ++----- src/components/PromptDialogHost.tsx | 72 ++++++++++++ src/components/SuccessNotificationModal.tsx | 14 ++- src/components/admin/ButtonsTab.tsx | 6 +- src/components/admin/MenuEditorTab.tsx | 6 +- src/components/admin/SettingInput.tsx | 2 +- src/components/admin/SettingsTableRow.tsx | 4 +- .../blocking/ChannelSubscriptionScreen.tsx | 39 ++++--- .../broadcasts/BroadcastPreview.tsx | 22 +++- src/components/common/PageLoader.tsx | 2 +- .../connection/blocks/BlockButtons.tsx | 12 +- .../dashboard/TrafficProgressBar.tsx | 2 +- src/components/layout/AppShell/AppShell.tsx | 19 +++- .../layout/AppShell/DesktopSidebar.tsx | 2 +- src/hooks/useFocusTrap.ts | 107 ++++++++++++++++++ src/pages/AdminCampaigns.tsx | 20 +++- src/pages/AdminChannelSubscriptions.tsx | 7 +- src/pages/AdminEmailTemplatePreview.tsx | 2 +- src/pages/AdminEmailTemplates.tsx | 14 ++- src/pages/AdminInfoPageEditor.tsx | 52 +++++---- src/pages/AdminNewsCreate.tsx | 25 ++-- src/pages/AdminPaymentMethodEdit.tsx | 2 +- src/pages/AdminPinnedMessages.tsx | 15 ++- src/pages/AdminPolicies.tsx | 15 ++- src/pages/AdminPromoGroups.tsx | 20 +++- src/pages/AdminPromocodes.tsx | 3 +- src/pages/AdminSettings.tsx | 2 +- src/pages/AdminTickets.tsx | 10 +- src/pages/AdminUserDetail.tsx | 3 +- src/pages/Contests.tsx | 4 +- src/pages/DeepLinkRedirect.tsx | 20 +--- src/pages/GiftResult.tsx | 3 +- src/pages/LinkTelegramCallback.tsx | 2 +- src/pages/OAuthCallback.tsx | 6 +- src/pages/Polls.tsx | 29 ++++- src/pages/Profile.tsx | 7 +- src/pages/Referral.tsx | 5 +- src/pages/ResetPassword.tsx | 4 +- src/pages/Subscription.tsx | 3 +- src/pages/TelegramCallback.tsx | 4 +- src/pages/TelegramRedirect.tsx | 2 +- src/pages/TopUpAmount.tsx | 5 +- src/pages/VerifyEmail.tsx | 2 +- src/pages/Wheel.tsx | 4 + src/store/promptDialog.ts | 60 ++++++++++ src/styles/globals.css | 12 ++ 48 files changed, 584 insertions(+), 173 deletions(-) create mode 100644 src/components/PromptDialogHost.tsx create mode 100644 src/hooks/useFocusTrap.ts create mode 100644 src/store/promptDialog.ts 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 && (