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
This commit is contained in:
c0mrade
2026-05-25 23:16:07 +03:00
parent 2bcba3b60f
commit 8e0b63bac8
48 changed files with 584 additions and 173 deletions

View File

@@ -6,6 +6,7 @@ import i18n from '../i18n';
import { campaignsApi, CampaignListItem, CampaignBonusType } from '../api/campaigns';
import { PlusIcon, EditIcon, TrashIcon, CheckIcon, XIcon, ChartIcon } from '../components/icons';
import { usePlatform } from '../platform/hooks/usePlatform';
import { useFocusTrap } from '../hooks/useFocusTrap';
const PAGE_SIZE = 50;
@@ -69,6 +70,9 @@ export default function AdminCampaigns() {
const { capabilities } = usePlatform();
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
const deleteDialogRef = useFocusTrap<HTMLDivElement>(deleteConfirm !== null, {
onEscape: () => setDeleteConfirm(null),
});
// Queries
const {
@@ -295,8 +299,20 @@ export default function AdminCampaigns() {
{/* Delete Confirmation */}
{deleteConfirm !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="w-full max-w-sm rounded-xl bg-dark-800 p-6">
<h3 className="mb-2 text-lg font-semibold text-dark-100">
<div
className="absolute inset-0 bg-black/60"
onClick={() => setDeleteConfirm(null)}
aria-hidden="true"
/>
<div
ref={deleteDialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="campaign-delete-title"
tabIndex={-1}
className="relative w-full max-w-sm rounded-xl bg-dark-800 p-6"
>
<h3 id="campaign-delete-title" className="mb-2 text-lg font-semibold text-dark-100">
{t('admin.campaigns.confirm.deleteTitle')}
</h3>
<p className="mb-6 text-dark-400">{t('admin.campaigns.confirm.deleteText')}</p>

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useHaptic, useNotify } from '../platform';
import { useNativeDialog } from '../platform/hooks/useNativeDialog';
import {
adminChannelsApi,
type RequiredChannel,
@@ -656,8 +657,10 @@ export default function AdminChannelSubscriptions() {
toggleMutation.mutate(id);
};
const handleDelete = (id: number) => {
if (window.confirm(t('admin.channelSubscriptions.deleteConfirm'))) {
const { confirm: confirmDialog } = useNativeDialog();
const handleDelete = async (id: number) => {
if (await confirmDialog(t('admin.channelSubscriptions.deleteConfirm'))) {
deleteMutation.mutate(id);
}
};

View File

@@ -69,7 +69,7 @@ export default function AdminEmailTemplatePreview() {
}
return (
<div className="flex h-[calc(100vh-120px)] flex-col">
<div className="flex h-[calc(100dvh-120px)] flex-col">
{/* Header */}
<div className="mb-4 flex items-center gap-3">
<button

View File

@@ -10,7 +10,9 @@ import {
} from '../api/adminEmailTemplates';
import { AdminBackButton, BackIcon } from '../components/admin';
import { useIsTelegram } from '../platform/hooks/usePlatform';
import { useNativeDialog } from '../platform/hooks/useNativeDialog';
import { useNotify } from '@/platform';
import { copyToClipboard } from '@/utils/clipboard';
// Hook to check if on mobile
function useIsMobile() {
@@ -162,6 +164,7 @@ function TemplateEditor({
const queryClient = useQueryClient();
const notify = useNotify();
const isTelegram = useIsTelegram();
const { confirm: confirmDialog } = useNativeDialog();
const isMobile = useIsMobile();
const isPreviewDisabled = isTelegram || isMobile;
@@ -303,8 +306,9 @@ function TemplateEditor({
return (
<button
key={lang}
onClick={() => {
if (isDirty && !window.confirm(t('admin.emailTemplates.unsavedWarning'))) return;
onClick={async () => {
if (isDirty && !(await confirmDialog(t('admin.emailTemplates.unsavedWarning'))))
return;
setActiveLang(lang);
}}
className={`flex flex-1 items-center justify-center gap-1 whitespace-nowrap rounded-md px-2 py-2 text-xs font-medium transition-all duration-150 sm:gap-1.5 sm:px-3 sm:text-sm ${
@@ -350,7 +354,7 @@ function TemplateEditor({
className="cursor-pointer rounded bg-dark-700 px-2 py-0.5 font-mono text-xs text-accent-400 transition-colors hover:bg-dark-600"
title={t('admin.emailTemplates.clickToCopy')}
onClick={() => {
navigator.clipboard.writeText(`{${v}}`);
void copyToClipboard(`{${v}}`);
notify.success(`Copied {${v}}`);
}}
>
@@ -425,8 +429,8 @@ function TemplateEditor({
{langData && !langData.is_default && (
<button
onClick={() => {
if (window.confirm(t('admin.emailTemplates.resetConfirm'))) {
onClick={async () => {
if (await confirmDialog(t('admin.emailTemplates.resetConfirm'))) {
resetMutation.mutate();
}
}}

View File

@@ -13,6 +13,7 @@ import HighlightExtension from '@tiptap/extension-highlight';
import { VideoExtension } from '../lib/tiptap-video';
import { infoPagesApi } from '../api/infoPages';
import { newsApi } from '../api/news';
import { usePrompt } from '../store/promptDialog';
import { AdminBackButton } from '../components/admin';
import { Toggle } from '../components/admin/Toggle';
import { useHapticFeedback } from '../platform/hooks/useHaptic';
@@ -372,18 +373,23 @@ function FaqAnswerEditor({ value, onChange }: { value: string; onChange: (html:
editor.setOptions({ editorProps: { ...editor.options.editorProps, handlePaste, handleDrop } });
}, [editor]);
const addLink = useCallback(() => {
const url = window.prompt(t('news.admin.toolbar.linkUrlPrompt'));
if (url && editor) {
try {
const parsed = new URL(url);
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return;
} catch {
return;
}
editor.chain().focus().setLink({ href: url }).run();
const promptDialog = usePrompt();
const addLink = useCallback(async () => {
if (!editor) return;
const url = await promptDialog({
label: t('news.admin.toolbar.linkUrlPrompt'),
inputType: 'url',
});
if (!url) return;
try {
const parsed = new URL(url);
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return;
} catch {
return;
}
}, [editor, t]);
editor.chain().focus().setLink({ href: url }).run();
}, [editor, t, promptDialog]);
if (!editor) return null;
@@ -1070,17 +1076,21 @@ export default function AdminInfoPageEditor() {
};
// Toolbar actions
const addLink = () => {
const url = window.prompt(t('news.admin.toolbar.linkUrlPrompt'));
if (url && editor) {
try {
const parsed = new URL(url);
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return;
} catch {
return;
}
editor.chain().focus().setLink({ href: url }).run();
const promptDialog = usePrompt();
const addLink = async () => {
if (!editor) return;
const url = await promptDialog({
label: t('news.admin.toolbar.linkUrlPrompt'),
inputType: 'url',
});
if (!url) return;
try {
const parsed = new URL(url);
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return;
} catch {
return;
}
editor.chain().focus().setLink({ href: url }).run();
};
if (isEdit && isLoadingPage) {

View File

@@ -12,6 +12,7 @@ import UnderlineExtension from '@tiptap/extension-underline';
import HighlightExtension from '@tiptap/extension-highlight';
import { VideoExtension } from '../lib/tiptap-video';
import { newsApi } from '../api/news';
import { usePrompt } from '../store/promptDialog';
import { AdminBackButton } from '../components/admin';
import { ColoredItemCombobox } from '../components/admin/ColoredItemCombobox';
import { Toggle } from '../components/admin/Toggle';
@@ -601,17 +602,21 @@ export default function AdminNewsCreate() {
};
// Toolbar actions
const addLink = () => {
const url = window.prompt(t('news.admin.toolbar.linkUrlPrompt'));
if (url && editor) {
try {
const parsed = new URL(url);
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return;
} catch {
return;
}
editor.chain().focus().setLink({ href: url }).run();
const promptDialog = usePrompt();
const addLink = async () => {
if (!editor) return;
const url = await promptDialog({
label: t('news.admin.toolbar.linkUrlPrompt'),
inputType: 'url',
});
if (!url) return;
try {
const parsed = new URL(url);
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return;
} catch {
return;
}
editor.chain().focus().setLink({ href: url }).run();
};
if (isEdit && isLoadingArticle) {

View File

@@ -141,7 +141,7 @@ export default function AdminPaymentMethodEdit() {
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="min-h-viewport flex items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
);

View File

@@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { adminPinnedMessagesApi, PinnedMessageResponse } from '../api/adminPinnedMessages';
import { AdminBackButton } from '../components/admin';
import { useNativeDialog } from '../platform/hooks/useNativeDialog';
// Icons
const PinIcon = () => (
@@ -316,20 +317,22 @@ export default function AdminPinnedMessages() {
deactivateMutation.mutate();
};
const handleUnpin = () => {
if (window.confirm(t('admin.pinnedMessages.unpinConfirm'))) {
const { confirm: confirmDialog } = useNativeDialog();
const handleUnpin = async () => {
if (await confirmDialog(t('admin.pinnedMessages.unpinConfirm'))) {
unpinMutation.mutate();
}
};
const handleBroadcast = (id: number) => {
if (window.confirm(t('admin.pinnedMessages.broadcastConfirm'))) {
const handleBroadcast = async (id: number) => {
if (await confirmDialog(t('admin.pinnedMessages.broadcastConfirm'))) {
broadcastMutation.mutate(id);
}
};
const handleDelete = (id: number) => {
if (window.confirm(t('admin.pinnedMessages.deleteConfirm'))) {
const handleDelete = async (id: number) => {
if (await confirmDialog(t('admin.pinnedMessages.deleteConfirm'))) {
deleteMutation.mutate(id);
}
};

View File

@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { rbacApi, AccessPolicy, AdminRole } from '@/api/rbac';
import { PermissionGate } from '@/components/auth/PermissionGate';
import { usePlatform } from '@/platform/hooks/usePlatform';
import { useFocusTrap } from '@/hooks/useFocusTrap';
const BackIcon = () => (
<svg
@@ -155,6 +156,9 @@ export default function AdminPolicies() {
const { capabilities } = usePlatform();
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
const deleteDialogRef = useFocusTrap<HTMLDivElement>(deleteConfirm !== null, {
onEscape: () => setDeleteConfirm(null),
});
const [formError, setFormError] = useState<string | null>(null);
// Queries
@@ -449,8 +453,15 @@ export default function AdminPolicies() {
onClick={() => setDeleteConfirm(null)}
aria-hidden="true"
/>
<div className="relative w-full max-w-sm rounded-xl border border-dark-700 bg-dark-800 p-6">
<h3 className="mb-2 text-lg font-semibold text-dark-100">
<div
ref={deleteDialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="policy-delete-title"
tabIndex={-1}
className="relative w-full max-w-sm rounded-xl border border-dark-700 bg-dark-800 p-6"
>
<h3 id="policy-delete-title" className="mb-2 text-lg font-semibold text-dark-100">
{t('admin.policies.confirm.title')}
</h3>
<p className="mb-6 text-dark-400">{t('admin.policies.confirm.text')}</p>

View File

@@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { promocodesApi, PromoGroup } from '../api/promocodes';
import { usePlatform } from '../platform/hooks/usePlatform';
import { useFocusTrap } from '../hooks/useFocusTrap';
// Icons
const BackIcon = () => (
@@ -61,6 +62,9 @@ export default function AdminPromoGroups() {
const { capabilities } = usePlatform();
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
const deleteDialogRef = useFocusTrap<HTMLDivElement>(!!deleteConfirm, {
onEscape: () => setDeleteConfirm(null),
});
// Query
const { data: groupsData, isLoading } = useQuery({
@@ -216,8 +220,20 @@ export default function AdminPromoGroups() {
{/* Delete Confirmation */}
{deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="w-full max-w-sm rounded-xl bg-dark-800 p-6">
<h3 className="mb-2 text-lg font-semibold text-dark-100">
<div
className="absolute inset-0 bg-black/60"
onClick={() => setDeleteConfirm(null)}
aria-hidden="true"
/>
<div
ref={deleteDialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="promo-group-delete-title"
tabIndex={-1}
className="relative w-full max-w-sm rounded-xl bg-dark-800 p-6"
>
<h3 id="promo-group-delete-title" className="mb-2 text-lg font-semibold text-dark-100">
{t('admin.promoGroups.confirm.title')}
</h3>
<p className="mb-6 text-dark-400">{t('admin.promoGroups.confirm.text')}</p>

View File

@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import i18n from '../i18n';
import { promocodesApi, PromoCode, PromoCodeType } from '../api/promocodes';
import { usePlatform } from '../platform/hooks/usePlatform';
import { copyToClipboard } from '../utils/clipboard';
// Icons
@@ -131,7 +132,7 @@ export default function AdminPromocodes() {
});
const handleCopyCode = (code: string) => {
navigator.clipboard.writeText(code);
void copyToClipboard(code);
setCopiedCode(code);
setTimeout(() => setCopiedCode(null), 2000);
};

View File

@@ -258,7 +258,7 @@ export default function AdminSettings() {
</div>
{/* Desktop Layout - fixed sidebar, scrollable content */}
<div className="hidden h-[calc(100vh-120px)] lg:flex">
<div className="hidden h-[calc(100dvh-120px)] lg:flex">
{/* Fixed Sidebar */}
<div className="w-[264px] shrink-0 overflow-y-auto border-r border-dark-700/50">
<div className="border-b border-dark-700/50 p-4">

View File

@@ -7,6 +7,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { adminApi, AdminTicket, AdminTicketDetail } from '../api/admin';
import { ticketsApi } from '../api/tickets';
import { copyToClipboard as copyText } from '../utils/clipboard';
import { usePlatform } from '../platform/hooks/usePlatform';
interface MediaAttachment {
@@ -257,14 +258,7 @@ export default function AdminTickets() {
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text).catch(() => {
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
});
void copyText(text);
};
return (

View File

@@ -5,6 +5,7 @@ import i18n from '../i18n';
import { DEVICE_ALIAS_MAX_LENGTH } from '../constants/devices';
import { useCurrency } from '../hooks/useCurrency';
import { useNotify } from '../platform/hooks/useNotify';
import { copyToClipboard as copyText } from '../utils/clipboard';
import {
adminUsersApi,
type UserDetailResponse,
@@ -1220,7 +1221,7 @@ export default function AdminUserDetail() {
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
await copyText(text);
notify.success(t('admin.users.detail.copied'));
} catch {}
};

View File

@@ -137,7 +137,7 @@ export default function Contests() {
{/* Render game based on type */}
{(gameData.game_type === 'quest' || gameData.game_type === 'locks') && (
<div className="grid grid-cols-5 gap-2">
<div className="grid grid-cols-3 gap-2 sm:grid-cols-5">
{Array.from({
length: gameData.game_data.total || gameData.game_data.grid_size || 9,
}).map((_, i) => (
@@ -154,7 +154,7 @@ export default function Contests() {
)}
{gameData.game_type === 'server' && (
<div className="grid grid-cols-5 gap-2">
<div className="grid grid-cols-3 gap-2 sm:grid-cols-5">
{gameData.game_data.flags?.map((flag: string, i: number) => (
<button
key={i}

View File

@@ -3,6 +3,7 @@ import { useSearchParams, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { brandingApi } from '../api/branding';
import { copyToClipboard } from '../utils/clipboard';
type Status = 'countdown' | 'fallback' | 'error';
@@ -132,20 +133,9 @@ export default function DeepLinkRedirect() {
if (copiedTimeoutRef.current) {
clearTimeout(copiedTimeoutRef.current);
}
try {
await navigator.clipboard.writeText(linkToCopy);
setCopied(true);
copiedTimeoutRef.current = setTimeout(() => setCopied(false), 2000);
} catch {
const textarea = document.createElement('textarea');
textarea.value = linkToCopy;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
setCopied(true);
copiedTimeoutRef.current = setTimeout(() => setCopied(false), 2000);
}
await copyToClipboard(linkToCopy);
setCopied(true);
copiedTimeoutRef.current = setTimeout(() => setCopied(false), 2000);
};
// Cleanup copied timeout on unmount
@@ -161,7 +151,7 @@ export default function DeepLinkRedirect() {
const progress = ((COUNTDOWN_SECONDS - countdown) / COUNTDOWN_SECONDS) * 100;
return (
<div className="flex min-h-screen items-center justify-center p-4">
<div className="min-h-viewport flex items-center justify-center p-4">
{/* Background */}
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
<div className="fixed inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-accent-500/10 via-transparent to-transparent" />

View File

@@ -8,6 +8,7 @@ import { Spinner } from '@/components/ui/Spinner';
import { AnimatedCheckmark } from '@/components/ui/AnimatedCheckmark';
import { AnimatedCrossmark } from '@/components/ui/AnimatedCrossmark';
import { cn } from '@/lib/utils';
import { copyToClipboard } from '@/utils/clipboard';
const MAX_POLL_MS = 10 * 60 * 1000; // 10 minutes
@@ -71,7 +72,7 @@ function CodeOnlySuccessState({
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(fullMessage);
await copyToClipboard(fullMessage);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {

View File

@@ -83,7 +83,7 @@ export default function LinkTelegramCallback() {
}, [searchParams, navigate, showToast, t]);
return (
<div className="flex min-h-screen items-center justify-center">
<div className="min-h-viewport flex items-center justify-center">
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
<div className="relative text-center">
<div className="mx-auto mb-4 h-10 w-10 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />

View File

@@ -140,7 +140,7 @@ export default function OAuthCallback() {
const telegramLink = botUsername ? `https://t.me/${botUsername}` : '';
return (
<div className="flex min-h-screen items-center justify-center px-4 py-8">
<div className="min-h-viewport flex items-center justify-center px-4 py-8">
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
<div className="relative w-full max-w-md text-center">
<div className="card">
@@ -204,7 +204,7 @@ export default function OAuthCallback() {
);
return (
<div className="flex min-h-screen items-center justify-center px-4 py-8">
<div className="min-h-viewport flex items-center justify-center px-4 py-8">
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
<div className="relative w-full max-w-md text-center">
<div className="card">
@@ -233,7 +233,7 @@ export default function OAuthCallback() {
}
return (
<div className="flex min-h-screen items-center justify-center">
<div className="min-h-viewport flex items-center justify-center">
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
<div className="relative text-center">
<div className="mx-auto mb-4 h-10 w-10 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { pollsApi, PollInfo, PollQuestion } from '../api/polls';
import { useFocusTrap } from '../hooks/useFocusTrap';
const ClipboardIcon = () => (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
@@ -107,6 +108,10 @@ export default function Polls() {
setCompletionMessage(null);
};
const pollDialogRef = useFocusTrap<HTMLDivElement>(!!selectedPoll, {
onEscape: handleClosePoll,
});
if (isLoading) {
return (
<div className="flex min-h-64 items-center justify-center">
@@ -133,10 +138,28 @@ export default function Polls() {
{/* Poll Modal */}
{selectedPoll && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="card max-h-[80vh] w-full max-w-lg overflow-y-auto">
<div
className="absolute inset-0 bg-black/60"
onClick={handleClosePoll}
aria-hidden="true"
/>
<div
ref={pollDialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="poll-dialog-title"
tabIndex={-1}
className="card relative max-h-[80vh] w-full max-w-lg overflow-y-auto"
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-bold">{selectedPoll.title}</h2>
<button onClick={handleClosePoll} className="text-dark-400 hover:text-dark-200">
<h2 id="poll-dialog-title" className="text-xl font-bold">
{selectedPoll.title}
</h2>
<button
onClick={handleClosePoll}
aria-label={t('common.close')}
className="text-dark-400 hover:text-dark-200"
>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"

View File

@@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from 'react';
import { Link, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { usePlatform } from '@/platform';
import { copyToClipboard } from '@/utils/clipboard';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { motion, AnimatePresence } from 'framer-motion';
import { useAuthStore } from '../store/auth';
@@ -115,7 +116,7 @@ export default function Profile() {
const copyReferralLink = () => {
if (referralLink) {
navigator.clipboard.writeText(referralLink);
void copyToClipboard(referralLink);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
@@ -140,7 +141,7 @@ export default function Profile() {
}
const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(referralLink)}&text=${encodeURIComponent(shareText)}`;
window.open(telegramUrl, '_blank', 'noopener,noreferrer');
openTelegramLink(telegramUrl);
};
const resendVerificationMutation = useMutation({
@@ -224,7 +225,7 @@ export default function Profile() {
}, [verificationResendCooldown]);
// Auto-focus inputs on step change (skip on Telegram — keyboard hides bottom nav)
const { platform: profilePlatform } = usePlatform();
const { platform: profilePlatform, openTelegramLink } = usePlatform();
useEffect(() => {
if (profilePlatform === 'telegram') return;
const timer = setTimeout(() => {

View File

@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { referralApi } from '../api/referral';
import { usePlatform } from '../platform';
import { copyToClipboard } from '../utils/clipboard';
import { brandingApi } from '../api/branding';
import { partnerApi } from '../api/partners';
@@ -226,6 +227,8 @@ export default function Referral() {
}
};
const { openTelegramLink } = usePlatform();
const shareLink = () => {
if (!referralLink) return;
const shareText = t('referral.shareMessage', {
@@ -247,7 +250,7 @@ export default function Referral() {
const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(
referralLink,
)}&text=${encodeURIComponent(shareText)}`;
window.open(telegramUrl, '_blank', 'noopener,noreferrer');
openTelegramLink(telegramUrl);
};
if (isLoading) {

View File

@@ -49,7 +49,7 @@ export default function ResetPassword() {
if (!token) {
return (
<div className="flex min-h-screen items-center justify-center px-4 py-8 sm:py-12">
<div className="min-h-viewport flex items-center justify-center px-4 py-8 sm:py-12">
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
<div className="fixed right-4 top-4 z-50">
<LanguageSwitcher />
@@ -76,7 +76,7 @@ export default function ResetPassword() {
}
return (
<div className="flex min-h-screen items-center justify-center px-4 py-8 sm:py-12">
<div className="min-h-viewport flex items-center justify-center px-4 py-8 sm:py-12">
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
<div className="fixed inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-accent-500/10 via-transparent to-transparent" />
<div className="fixed right-4 top-4 z-50">

View File

@@ -12,6 +12,7 @@ import { HoverBorderGradient } from '../components/ui/hover-border-gradient';
import { useTrafficZone } from '../hooks/useTrafficZone';
import { formatTraffic } from '../utils/formatTraffic';
import { getGlassColors } from '../utils/glassTheme';
import { copyToClipboard } from '../utils/clipboard';
import { useTheme } from '../hooks/useTheme';
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
import { useCurrency } from '../hooks/useCurrency';
@@ -554,7 +555,7 @@ export default function Subscription() {
const copyUrl = () => {
if (displayedConnectionUrl) {
navigator.clipboard.writeText(displayedConnectionUrl);
void copyToClipboard(displayedConnectionUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}

View File

@@ -64,7 +64,7 @@ export default function TelegramCallback() {
if (error) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-8">
<div className="min-h-viewport flex items-center justify-center bg-gray-50 px-4 py-8">
<div className="w-full max-w-md text-center">
<div className="mb-4 text-5xl text-red-500"></div>
<h2 className="mb-2 text-lg font-semibold text-gray-900">{t('auth.loginFailed')}</h2>
@@ -78,7 +78,7 @@ export default function TelegramCallback() {
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="min-h-viewport flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="border-primary-600 mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-b-2"></div>
<h2 className="text-lg font-semibold text-gray-900">{t('auth.authenticating')}</h2>

View File

@@ -139,7 +139,7 @@ export default function TelegramRedirect() {
}, [status]);
return (
<div className="flex min-h-screen items-center justify-center p-4">
<div className="min-h-viewport flex items-center justify-center p-4">
{/* Background */}
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
<div className="fixed inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-accent-500/10 via-transparent to-transparent" />

View File

@@ -13,6 +13,7 @@ import { staggerContainer, staggerItem } from '@/components/motion/transitions';
import type { PaymentMethod, PaymentMethodOption } from '../types';
import BentoCard from '../components/ui/BentoCard';
import { saveTopUpPendingInfo } from '../utils/topUpStorage';
import { copyToClipboard } from '@/utils/clipboard';
// Icons
const StarIcon = () => (
@@ -400,7 +401,7 @@ export default function TopUpAmount() {
const handleCopyUrl = async () => {
if (!paymentUrl) return;
try {
await navigator.clipboard.writeText(paymentUrl);
await copyToClipboard(paymentUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
@@ -522,7 +523,7 @@ export default function TopUpAmount() {
{/* Quick amount buttons */}
{quickAmounts.length > 0 && (
<motion.div variants={staggerItem} className="grid grid-cols-4 gap-2">
<motion.div variants={staggerItem} className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{quickAmounts.map((a) => {
const val = getQuickValue(a);
const isSelected = amount === val;

View File

@@ -66,7 +66,7 @@ export default function VerifyEmail() {
}, [searchParams, t, navigate, setTokens, setUser, checkAdminStatus]);
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-8 sm:py-12">
<div className="min-h-viewport flex items-center justify-center bg-gray-50 px-4 py-8 sm:py-12">
{/* Language switcher in corner */}
<div className="fixed right-4 top-4 z-50">
<LanguageSwitcher />

View File

@@ -368,6 +368,10 @@ export default function Wheel() {
setIsPayingStars(true);
// In browser: pre-open window synchronously (direct user gesture) to avoid popup blocker
if (!capabilities.hasInvoice) {
// Web-only: synchronously pre-open a tab during the user gesture to dodge the
// popup blocker before the async invoice URL resolves. Not reached in Telegram
// (hasInvoice is true there, so the native invoice flow is used instead).
// eslint-disable-next-line no-restricted-properties
preOpenedWindowRef.current = window.open('about:blank', '_blank') || null;
}
starsInvoiceMutation.mutate();