mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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:
@@ -65,7 +65,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
||||
|
||||
if (level === 'app') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-dark-900 p-4">
|
||||
<div className="min-h-viewport flex items-center justify-center bg-dark-900 p-4">
|
||||
<div className="max-w-md text-center">
|
||||
<div className="mb-4 text-4xl">⚠️</div>
|
||||
<h1 className="mb-2 text-xl font-bold text-dark-50">Something went wrong</h1>
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
72
src/components/PromptDialogHost.tsx
Normal file
72
src/components/PromptDialogHost.tsx
Normal file
@@ -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<HTMLFormElement>(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(
|
||||
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={cancel} aria-hidden="true" />
|
||||
<form
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={request.title ?? request.label}
|
||||
onSubmit={handleSubmit}
|
||||
className="card relative w-full max-w-sm space-y-4"
|
||||
>
|
||||
{request.title && <h2 className="text-lg font-semibold text-dark-50">{request.title}</h2>}
|
||||
<label className="block">
|
||||
<span className="label">{request.label}</span>
|
||||
<input
|
||||
type={request.inputType ?? 'text'}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={request.placeholder}
|
||||
className="input"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={cancel} className="btn-secondary">
|
||||
{request.cancelLabel ?? t('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" className="btn-primary">
|
||||
{request.submitLabel ?? t('common.ok', 'OK')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement>(isOpen, { lockScroll: false });
|
||||
|
||||
// Escape key to close
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -205,6 +209,11 @@ export default function SuccessNotificationModal() {
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
ref={modalRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="success-modal-title"
|
||||
tabIndex={-1}
|
||||
className="relative mx-4 w-full max-w-sm overflow-hidden rounded-3xl border border-dark-700/50 bg-dark-900 shadow-2xl"
|
||||
style={{
|
||||
marginBottom: safeBottom ? `${safeBottom}px` : undefined,
|
||||
@@ -214,6 +223,7 @@ export default function SuccessNotificationModal() {
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
aria-label={t('common.close')}
|
||||
className="absolute right-3 top-3 z-10 rounded-xl p-2 text-dark-400 transition-colors hover:bg-dark-800 hover:text-dark-200"
|
||||
>
|
||||
<CloseIcon />
|
||||
@@ -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`}
|
||||
>
|
||||
<div className="mb-4 animate-bounce text-white">{icon}</div>
|
||||
<h2 className="text-center text-2xl font-bold text-white">{title}</h2>
|
||||
<h2 id="success-modal-title" className="text-center text-2xl font-bold text-white">
|
||||
{title}
|
||||
</h2>
|
||||
{message && <p className="mt-2 text-center text-white/80">{message}</p>}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm(t('admin.buttons.resetConfirm'))) {
|
||||
onClick={async () => {
|
||||
if (await confirmDialog(t('admin.buttons.resetConfirm'))) {
|
||||
resetMutation.mutate();
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from '../../api/menuLayout';
|
||||
import { Toggle } from './Toggle';
|
||||
import { useNotify } from '../../platform/hooks/useNotify';
|
||||
import { useNativeDialog } from '../../platform/hooks/useNativeDialog';
|
||||
|
||||
const GripIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
@@ -532,6 +533,7 @@ export function MenuEditorTab() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const notify = useNotify();
|
||||
const { confirm: confirmDialog } = useNativeDialog();
|
||||
|
||||
// Fetch config
|
||||
const {
|
||||
@@ -868,8 +870,8 @@ export function MenuEditorTab() {
|
||||
{/* Reset */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm(t('admin.menuEditor.resetConfirm'))) {
|
||||
onClick={async () => {
|
||||
if (await confirmDialog(t('admin.menuEditor.resetConfirm'))) {
|
||||
resetMutation.mutate();
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -187,7 +187,7 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
||||
className="group flex min-w-[100px] max-w-[200px] items-center gap-2 truncate rounded-lg border border-dark-600 bg-dark-700 px-3 py-2.5 text-left font-mono text-sm text-dark-200 transition-colors hover:border-dark-500 hover:bg-dark-600 disabled:opacity-50"
|
||||
>
|
||||
<span className="flex-1 truncate">{currentValue || '-'}</span>
|
||||
<span className="text-dark-500 opacity-0 transition-colors group-hover:text-accent-400 group-hover:opacity-100">
|
||||
<span className="text-dark-500 opacity-0 transition-colors group-focus-within:text-accent-400 group-focus-within:opacity-100 group-hover:text-accent-400 group-hover:opacity-100 [@media(hover:none)]:opacity-100">
|
||||
<EditIcon />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -142,7 +142,7 @@ export function SettingsTableRow({
|
||||
<button
|
||||
onClick={onReset}
|
||||
disabled={isResetting}
|
||||
className="flex-shrink-0 rounded-lg p-1.5 text-dark-500 opacity-0 transition-all hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50 group-hover:opacity-100 max-lg:opacity-100"
|
||||
className="flex-shrink-0 rounded-lg p-1.5 text-dark-500 opacity-0 transition-all hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50 group-focus-within:opacity-100 group-hover:opacity-100 max-lg:opacity-100 [@media(hover:none)]:opacity-100"
|
||||
title={t('admin.settings.reset')}
|
||||
aria-label={t('admin.settings.reset')}
|
||||
>
|
||||
@@ -157,7 +157,7 @@ export function SettingsTableRow({
|
||||
'flex-shrink-0 rounded-lg p-1.5 transition-all',
|
||||
isFavorite
|
||||
? 'text-warning-400 hover:bg-warning-500/15'
|
||||
: 'text-dark-500 opacity-0 hover:bg-dark-700/50 hover:text-warning-400 group-hover:opacity-100 max-lg:opacity-100',
|
||||
: 'text-dark-500 opacity-0 hover:bg-dark-700/50 hover:text-warning-400 group-focus-within:opacity-100 group-hover:opacity-100 max-lg:opacity-100 [@media(hover:none)]:opacity-100',
|
||||
)}
|
||||
title={
|
||||
isFavorite
|
||||
|
||||
@@ -2,21 +2,10 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useBlockingStore } from '../../store/blocking';
|
||||
import { apiClient, isChannelSubscriptionError } from '../../api/client';
|
||||
import { usePlatform } from '../../platform';
|
||||
|
||||
const CHECK_COOLDOWN_SECONDS = 5;
|
||||
|
||||
function safeOpenUrl(url: string | undefined | null): void {
|
||||
if (!url) return;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
|
||||
window.open(url, '_blank', 'noopener');
|
||||
}
|
||||
} catch {
|
||||
// invalid URL, do nothing
|
||||
}
|
||||
}
|
||||
|
||||
export default function ChannelSubscriptionScreen() {
|
||||
const { t } = useTranslation();
|
||||
const channelInfo = useBlockingStore((state) => state.channelInfo);
|
||||
@@ -25,6 +14,28 @@ export default function ChannelSubscriptionScreen() {
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isCheckingRef = useRef(false);
|
||||
const { openLink, openTelegramLink } = usePlatform();
|
||||
|
||||
// Route channel links through the platform adapter: inside the Telegram
|
||||
// WebView a raw window.open is intercepted by the client and the link
|
||||
// silently fails to open. t.me links use openTelegramLink; others openLink.
|
||||
const openChannel = useCallback(
|
||||
(url: string | undefined | null) => {
|
||||
if (!url) return;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return;
|
||||
if (parsed.hostname === 't.me' || parsed.hostname.endsWith('.t.me')) {
|
||||
openTelegramLink(url);
|
||||
} else {
|
||||
openLink(url);
|
||||
}
|
||||
} catch {
|
||||
// invalid URL, do nothing
|
||||
}
|
||||
},
|
||||
[openLink, openTelegramLink],
|
||||
);
|
||||
|
||||
// Cooldown timer
|
||||
useEffect(() => {
|
||||
@@ -100,7 +111,7 @@ export default function ChannelSubscriptionScreen() {
|
||||
<span className="text-sm font-medium text-white">{ch.title || ch.channel_id}</span>
|
||||
{ch.channel_link && (
|
||||
<button
|
||||
onClick={() => safeOpenUrl(ch.channel_link)}
|
||||
onClick={() => openChannel(ch.channel_link)}
|
||||
className="rounded-lg bg-blue-500/20 px-3 py-1 text-xs font-medium text-blue-400 hover:bg-blue-500/30"
|
||||
>
|
||||
{t('blocking.channel.openChannel')}
|
||||
@@ -114,7 +125,7 @@ export default function ChannelSubscriptionScreen() {
|
||||
{/* Fallback: single channel (legacy) */}
|
||||
{channels.length === 0 && channelInfo?.channel_link && (
|
||||
<button
|
||||
onClick={() => safeOpenUrl(channelInfo.channel_link)}
|
||||
onClick={() => openChannel(channelInfo.channel_link)}
|
||||
className="mb-6 flex w-full items-center justify-center gap-3 rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500 px-6 py-4 font-semibold text-white transition-all duration-200 hover:from-blue-600 hover:to-cyan-600"
|
||||
>
|
||||
{t('blocking.channel.openChannel')}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFocusTrap } from '../../hooks/useFocusTrap';
|
||||
|
||||
interface PreviewButton {
|
||||
text: string;
|
||||
@@ -185,6 +186,7 @@ export function TelegramPreview({
|
||||
}: TelegramPreviewProps) {
|
||||
const { t } = useTranslation();
|
||||
const rendered = useMemo(() => tokensToReact(tokenize(text)), [text]);
|
||||
const dialogRef = useFocusTrap<HTMLDivElement>(open, { onEscape: onClose });
|
||||
if (!open) return null;
|
||||
return createPortal(
|
||||
<div
|
||||
@@ -192,6 +194,11 @@ export function TelegramPreview({
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('admin.broadcasts.preview', 'Предпросмотр Telegram')}
|
||||
tabIndex={-1}
|
||||
className="w-full max-w-md rounded-2xl bg-[#17212b] p-4 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -199,7 +206,11 @@ export function TelegramPreview({
|
||||
<h3 className="text-base font-semibold text-white">
|
||||
{t('admin.broadcasts.preview', 'Предпросмотр Telegram')}
|
||||
</h3>
|
||||
<button onClick={onClose} className="rounded p-1 text-dark-400 hover:bg-dark-700">
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label={t('common.close', 'Закрыть')}
|
||||
className="flex h-9 w-9 items-center justify-center rounded text-dark-400 hover:bg-dark-700"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
@@ -247,6 +258,7 @@ export function TelegramPreview({
|
||||
|
||||
export function EmailPreview({ open, onClose, subject, htmlContent }: EmailPreviewProps) {
|
||||
const { t } = useTranslation();
|
||||
const dialogRef = useFocusTrap<HTMLDivElement>(open, { onEscape: onClose });
|
||||
if (!open) return null;
|
||||
const emptyHtml = `<p style="color:#999;font-family:sans-serif">${t('admin.broadcasts.previewEmpty', '— пусто —')}</p>`;
|
||||
return createPortal(
|
||||
@@ -255,6 +267,11 @@ export function EmailPreview({ open, onClose, subject, htmlContent }: EmailPrevi
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={subject || t('admin.broadcasts.emailSubject', 'Email')}
|
||||
tabIndex={-1}
|
||||
className="flex h-[80vh] w-full max-w-2xl flex-col rounded-2xl bg-white shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -269,7 +286,8 @@ export function EmailPreview({ open, onClose, subject, htmlContent }: EmailPrevi
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-3 rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
|
||||
aria-label={t('common.close', 'Закрыть')}
|
||||
className="ml-3 flex h-9 w-9 items-center justify-center rounded text-gray-400 hover:bg-gray-100 hover:text-gray-700"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function PageLoader({ variant = 'dark' }: PageLoaderProps) {
|
||||
const spinnerColor = variant === 'dark' ? 'border-accent-500' : 'border-blue-500';
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="min-h-viewport flex items-center justify-center">
|
||||
<div
|
||||
className={`h-10 w-10 border-[3px] ${spinnerColor} animate-spin rounded-full border-t-transparent`}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
@@ -187,7 +187,7 @@ export default function TrafficProgressBar({
|
||||
{/* Scale labels */}
|
||||
{!compact && limitGb > 0 && (
|
||||
<div
|
||||
className="mt-1.5 flex justify-between px-0.5 font-mono text-[9px] font-medium text-dark-50/20"
|
||||
className="mt-1.5 flex justify-between px-0.5 font-mono text-[10px] font-medium text-dark-50/20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{[0, 25, 50, 75, 100].map((v) => (
|
||||
|
||||
@@ -18,6 +18,7 @@ import { cn } from '@/lib/utils';
|
||||
import WebSocketNotifications from '@/components/WebSocketNotifications';
|
||||
import CampaignBonusNotifier from '@/components/CampaignBonusNotifier';
|
||||
import SuccessNotificationModal from '@/components/SuccessNotificationModal';
|
||||
import { PromptDialogHost } from '@/components/PromptDialogHost';
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
||||
import TicketNotificationBell from '@/components/TicketNotificationBell';
|
||||
import { SubscriptionIcon, GiftIcon } from '@/components/icons';
|
||||
@@ -278,7 +279,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||
// headerHeight comes from useHeaderHeight() — accounts for TG safe area in fullscreen
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="min-h-viewport">
|
||||
{/* Animated background renders via portal on document.body at z-index: -1 */}
|
||||
<BackgroundRenderer />
|
||||
|
||||
@@ -286,6 +287,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||
<WebSocketNotifications />
|
||||
<CampaignBonusNotifier />
|
||||
<SuccessNotificationModal />
|
||||
<PromptDialogHost />
|
||||
|
||||
{/* Desktop Header */}
|
||||
<header className="fixed left-0 right-0 top-0 z-50 hidden border-b border-dark-800/50 bg-dark-950/95 lg:block">
|
||||
@@ -322,6 +324,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={handleNavClick}
|
||||
aria-label={item.label}
|
||||
className={cn(
|
||||
'group flex items-center rounded-xl px-2.5 py-2 transition-all duration-200',
|
||||
isActive(item.path)
|
||||
@@ -330,7 +333,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-[18px] w-[18px] shrink-0" />
|
||||
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
|
||||
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-focus-within:ml-2 group-focus-within:max-w-40 group-focus-within:opacity-100 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
|
||||
{item.label}
|
||||
</span>
|
||||
</Link>
|
||||
@@ -339,6 +342,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||
<Link
|
||||
to="/referral"
|
||||
onClick={handleNavClick}
|
||||
aria-label={t('nav.referral')}
|
||||
className={cn(
|
||||
'group flex items-center rounded-xl px-2.5 py-2 transition-all duration-200',
|
||||
isActive('/referral')
|
||||
@@ -347,7 +351,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||
)}
|
||||
>
|
||||
<UsersIcon className="h-[18px] w-[18px] shrink-0" />
|
||||
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
|
||||
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-focus-within:ml-2 group-focus-within:max-w-40 group-focus-within:opacity-100 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
|
||||
{t('nav.referral')}
|
||||
</span>
|
||||
</Link>
|
||||
@@ -356,6 +360,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||
<Link
|
||||
to="/gift"
|
||||
onClick={handleNavClick}
|
||||
aria-label={t('nav.gift')}
|
||||
className={cn(
|
||||
'group flex items-center rounded-xl px-2.5 py-2 transition-all duration-200',
|
||||
isActive('/gift')
|
||||
@@ -364,7 +369,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||
)}
|
||||
>
|
||||
<GiftIcon className="h-[18px] w-[18px] shrink-0" />
|
||||
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
|
||||
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-focus-within:ml-2 group-focus-within:max-w-40 group-focus-within:opacity-100 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
|
||||
{t('nav.gift')}
|
||||
</span>
|
||||
</Link>
|
||||
@@ -375,6 +380,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||
<Link
|
||||
to="/admin"
|
||||
onClick={handleNavClick}
|
||||
aria-label={t('admin.nav.title')}
|
||||
className={cn(
|
||||
'group flex items-center rounded-xl px-2.5 py-2 transition-all duration-200',
|
||||
location.pathname.startsWith('/admin')
|
||||
@@ -383,7 +389,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||
)}
|
||||
>
|
||||
<ShieldIcon className="h-[18px] w-[18px] shrink-0" />
|
||||
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
|
||||
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-focus-within:ml-2 group-focus-within:max-w-40 group-focus-within:opacity-100 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
|
||||
{t('admin.nav.title')}
|
||||
</span>
|
||||
</Link>
|
||||
@@ -402,6 +408,9 @@ export function AppShell({ children }: AppShellProps) {
|
||||
'rounded-xl border border-dark-700/50 bg-dark-800/50 p-2 text-dark-400 transition-colors duration-200 hover:bg-dark-700 hover:text-accent-400',
|
||||
!canToggleTheme && 'pointer-events-none invisible',
|
||||
)}
|
||||
aria-label={
|
||||
isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'
|
||||
}
|
||||
title={isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'}
|
||||
>
|
||||
{isDark ? <MoonIcon className="h-5 w-5" /> : <SunIcon className="h-5 w-5" />}
|
||||
|
||||
@@ -96,7 +96,7 @@ export function DesktopSidebar({
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="fixed left-0 top-0 z-40 flex h-screen w-60 flex-col border-r border-dark-700/30 bg-dark-950/80 backdrop-blur-linear">
|
||||
<aside className="h-viewport fixed left-0 top-0 z-40 flex w-60 flex-col border-r border-dark-700/30 bg-dark-950/80 backdrop-blur-linear">
|
||||
{/* Logo */}
|
||||
<div className="flex h-16 items-center gap-3 border-b border-dark-700/30 px-4">
|
||||
<Link to="/" className="flex items-center gap-3" onClick={handleNavClick}>
|
||||
|
||||
107
src/hooks/useFocusTrap.ts
Normal file
107
src/hooks/useFocusTrap.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { RefObject } from 'react';
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',');
|
||||
|
||||
interface FocusTrapOptions {
|
||||
/** Called when Escape is pressed while the trap is active. */
|
||||
onEscape?: () => void;
|
||||
/** Lock body scroll while active. Default: true. Set false if the caller already locks scroll. */
|
||||
lockScroll?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Traps keyboard focus inside a container while `active`.
|
||||
* - Moves focus into the container on activation, restores it on deactivation.
|
||||
* - Cycles Tab / Shift+Tab within the container (no escape into the page behind).
|
||||
* - Optionally closes on Escape and locks body scroll.
|
||||
*
|
||||
* Attach the returned ref to the dialog element and give it
|
||||
* `role="dialog" aria-modal="true" tabIndex={-1}`.
|
||||
*
|
||||
* @example
|
||||
* const dialogRef = useFocusTrap<HTMLDivElement>(isOpen, { onEscape: close });
|
||||
* return <div ref={dialogRef} role="dialog" aria-modal="true" tabIndex={-1}>...</div>;
|
||||
*/
|
||||
export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(
|
||||
active: boolean,
|
||||
options: FocusTrapOptions = {},
|
||||
): RefObject<T | null> {
|
||||
const { lockScroll = true } = options;
|
||||
const containerRef = useRef<T>(null);
|
||||
// Keep the latest onEscape without re-running the effect on every render
|
||||
// (which would otherwise yank focus back to the first element repeatedly).
|
||||
const onEscapeRef = useRef(options.onEscape);
|
||||
onEscapeRef.current = options.onEscape;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
|
||||
const getFocusable = () =>
|
||||
Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
||||
(el) => el.getClientRects().length > 0,
|
||||
);
|
||||
|
||||
// Move focus into the dialog.
|
||||
const initial = getFocusable();
|
||||
(initial[0] ?? container).focus();
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && onEscapeRef.current) {
|
||||
e.preventDefault();
|
||||
onEscapeRef.current();
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
const focusable = getFocusable();
|
||||
if (focusable.length === 0) {
|
||||
e.preventDefault();
|
||||
container.focus();
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const activeEl = document.activeElement;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (activeEl === first || !container.contains(activeEl)) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else if (activeEl === last || !container.contains(activeEl)) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
let prevOverflow: string | undefined;
|
||||
if (lockScroll) {
|
||||
prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
if (lockScroll) {
|
||||
document.body.style.overflow = prevOverflow ?? '';
|
||||
}
|
||||
previouslyFocused?.focus?.();
|
||||
};
|
||||
}, [active, lockScroll]);
|
||||
|
||||
return containerRef;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 {}
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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();
|
||||
|
||||
60
src/store/promptDialog.ts
Normal file
60
src/store/promptDialog.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface PromptOptions {
|
||||
/** Field label shown above the input. */
|
||||
label: string;
|
||||
/** Optional heading. */
|
||||
title?: string;
|
||||
initialValue?: string;
|
||||
placeholder?: string;
|
||||
submitLabel?: string;
|
||||
cancelLabel?: string;
|
||||
/** Input type, e.g. 'text' (default) or 'url'. */
|
||||
inputType?: string;
|
||||
}
|
||||
|
||||
interface PromptRequest extends PromptOptions {
|
||||
resolve: (value: string | null) => void;
|
||||
}
|
||||
|
||||
interface PromptState {
|
||||
request: PromptRequest | null;
|
||||
/**
|
||||
* Open a prompt dialog and resolve with the entered value, or null if cancelled.
|
||||
* Cross-platform replacement for window.prompt (which is unavailable in the Telegram WebView).
|
||||
*/
|
||||
prompt: (options: PromptOptions) => Promise<string | null>;
|
||||
submit: (value: string) => void;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export const usePromptStore = create<PromptState>((set, get) => ({
|
||||
request: null,
|
||||
|
||||
prompt: (options) =>
|
||||
new Promise<string | null>((resolve) => {
|
||||
// If a prompt is already open, cancel it before replacing.
|
||||
const existing = get().request;
|
||||
if (existing) existing.resolve(null);
|
||||
set({ request: { ...options, resolve } });
|
||||
}),
|
||||
|
||||
submit: (value) => {
|
||||
const req = get().request;
|
||||
if (!req) return;
|
||||
req.resolve(value);
|
||||
set({ request: null });
|
||||
},
|
||||
|
||||
cancel: () => {
|
||||
const req = get().request;
|
||||
if (!req) return;
|
||||
req.resolve(null);
|
||||
set({ request: null });
|
||||
},
|
||||
}));
|
||||
|
||||
/** Returns a stable function that opens a prompt dialog and resolves with the value (or null). */
|
||||
export function usePrompt() {
|
||||
return usePromptStore((s) => s.prompt);
|
||||
}
|
||||
@@ -1111,6 +1111,18 @@ img.twemoji {
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Viewport height — correct across web (dvh) and Telegram Mini App.
|
||||
main.tsx binds --tg-viewport-stable-height via bindViewportCssVars();
|
||||
on the standalone web build the var is undefined and falls back to 100dvh. */
|
||||
.min-h-viewport {
|
||||
min-height: 100dvh;
|
||||
min-height: var(--tg-viewport-stable-height, 100dvh);
|
||||
}
|
||||
.h-viewport {
|
||||
height: 100dvh;
|
||||
height: var(--tg-viewport-stable-height, 100dvh);
|
||||
}
|
||||
|
||||
/* Text gradient */
|
||||
.text-gradient {
|
||||
@apply bg-gradient-to-r from-accent-400 to-accent-600 bg-clip-text text-transparent;
|
||||
|
||||
Reference in New Issue
Block a user