fix: adapt referral network for Telegram MiniApp safe areas

- Extract useHeaderHeight() hook — single source of truth for header
  height across AppShell, ReferralNetwork, and TicketNotificationBell
- Portal top offset now computed dynamically from TG safe area insets
  instead of hardcoded top-16 (fixes overlap with TG header bar)
- Two-row mobile layout: title + selector on separate rows so dropdown
  gets full width on small screens
- Add MOBILE_HEADER_HEIGHT_PX and DESKTOP_HEADER_HEIGHT_PX to constants
- Fix TicketNotificationBell using hardcoded 45px for all platforms
This commit is contained in:
Fringg
2026-03-20 04:23:45 +03:00
parent 2780898d1c
commit 33486a09d0
5 changed files with 47 additions and 26 deletions

View File

@@ -6,7 +6,7 @@ import { ticketNotificationsApi } from '../api/ticketNotifications';
import { useAuthStore } from '../store/auth'; import { useAuthStore } from '../store/auth';
import { useToast } from './Toast'; import { useToast } from './Toast';
import { useWebSocket, WSMessage } from '../hooks/useWebSocket'; import { useWebSocket, WSMessage } from '../hooks/useWebSocket';
import { useTelegramSDK } from '../hooks/useTelegramSDK'; import { useHeaderHeight } from '../hooks/useHeaderHeight';
import type { TicketNotification } from '../types'; import type { TicketNotification } from '../types';
const BellIcon = () => ( const BellIcon = () => (
@@ -37,12 +37,7 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
const { showToast } = useToast(); const { showToast } = useToast();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramSDK(); const { mobile: dropdownTop } = useHeaderHeight();
// Calculate dropdown top position (account for fullscreen safe area + TG buttons)
const dropdownTop = isFullscreen
? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45 + 64 // safe area + TG buttons + header
: 64; // default header height
// Show toast for WebSocket notification // Show toast for WebSocket notification
const showWSNotificationToast = useCallback( const showWSNotificationToast = useCallback(
@@ -259,10 +254,8 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
{/* Dropdown */} {/* Dropdown */}
{isOpen && ( {isOpen && (
<div <div
className={`fixed left-4 right-4 z-50 mt-0 w-auto animate-scale-in overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-900/95 shadow-2xl shadow-black/30 backdrop-blur-xl sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:mt-2 sm:w-96 ${ className="fixed left-4 right-4 z-50 mt-0 w-auto animate-scale-in overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-900/95 shadow-2xl shadow-black/30 backdrop-blur-xl sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:mt-2 sm:w-96"
!isFullscreen ? 'top-16' : '' style={{ top: dropdownTop }}
}`}
style={isFullscreen ? { top: `${dropdownTop}px` } : undefined}
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between border-b border-dark-700/50 bg-dark-800/30 px-4 py-3"> <div className="flex items-center justify-between border-b border-dark-700/50 bg-dark-800/30 px-4 py-3">

View File

@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/store/auth'; import { useAuthStore } from '@/store/auth';
import { useHaptic } from '@/platform'; import { useHaptic } from '@/platform';
import { useTelegramSDK } from '@/hooks/useTelegramSDK'; import { useTelegramSDK } from '@/hooks/useTelegramSDK';
import { useHeaderHeight } from '@/hooks/useHeaderHeight';
import { useTheme } from '@/hooks/useTheme'; import { useTheme } from '@/hooks/useTheme';
import { useBranding } from '@/hooks/useBranding'; import { useBranding } from '@/hooks/useBranding';
import { useFeatureFlags } from '@/hooks/useFeatureFlags'; import { useFeatureFlags } from '@/hooks/useFeatureFlags';
@@ -13,7 +14,6 @@ import { useScrollRestoration } from '@/hooks/useScrollRestoration';
import { themeColorsApi } from '@/api/themeColors'; import { themeColorsApi } from '@/api/themeColors';
import { isLogoPreloaded } from '@/api/branding'; import { isLogoPreloaded } from '@/api/branding';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { UI } from '@/config/constants';
import WebSocketNotifications from '@/components/WebSocketNotifications'; import WebSocketNotifications from '@/components/WebSocketNotifications';
import CampaignBonusNotifier from '@/components/CampaignBonusNotifier'; import CampaignBonusNotifier from '@/components/CampaignBonusNotifier';
@@ -198,6 +198,7 @@ export function AppShell({ children }: AppShellProps) {
const logout = useAuthStore((state) => state.logout); const logout = useAuthStore((state) => state.logout);
const { isFullscreen, safeAreaInset, contentSafeAreaInset, platform, isMobile } = const { isFullscreen, safeAreaInset, contentSafeAreaInset, platform, isMobile } =
useTelegramSDK(); useTelegramSDK();
const { mobile: headerHeight } = useHeaderHeight();
const haptic = useHaptic(); const haptic = useHaptic();
const { toggleTheme, isDark } = useTheme(); const { toggleTheme, isDark } = useTheme();
@@ -269,14 +270,7 @@ export function AppShell({ children }: AppShellProps) {
haptic.impact('light'); haptic.impact('light');
}; };
// Calculate header height based on fullscreen mode (only on mobile Telegram) // headerHeight comes from useHeaderHeight() — accounts for TG safe area in fullscreen
// On iOS: contentSafeAreaInset.top includes status bar + dynamic island + Telegram header
// On Android: safeAreaInset.top only includes status bar, need to add Telegram header height (~48px)
const telegramHeaderHeight =
platform === 'android' ? UI.TELEGRAM_HEADER_ANDROID_PX : UI.TELEGRAM_HEADER_IOS_PX;
const headerHeight = isMobileFullscreen
? 64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + telegramHeaderHeight
: 64;
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">

View File

@@ -17,6 +17,8 @@ export const UI = {
RESEND_COOLDOWN_SEC: 60, RESEND_COOLDOWN_SEC: 60,
TELEGRAM_HEADER_ANDROID_PX: 48, TELEGRAM_HEADER_ANDROID_PX: 48,
TELEGRAM_HEADER_IOS_PX: 45, TELEGRAM_HEADER_IOS_PX: 45,
MOBILE_HEADER_HEIGHT_PX: 64,
DESKTOP_HEADER_HEIGHT_PX: 56,
} as const; } as const;
// API // API

View File

@@ -0,0 +1,25 @@
import { useTelegramSDK } from '@/hooks/useTelegramSDK';
import { UI } from '@/config/constants';
/**
* Computes the app header height in pixels, accounting for
* Telegram MiniApp safe area insets in fullscreen mode.
*
* Desktop: 56px (h-14). Mobile: 64px (h-16) + safe area + TG header when fullscreen.
*/
export function useHeaderHeight(): { mobile: number; desktop: number } {
const { isFullscreen, safeAreaInset, contentSafeAreaInset, platform, isMobile } =
useTelegramSDK();
const isMobileFullscreen = isFullscreen && isMobile;
const telegramHeaderHeight =
platform === 'android' ? UI.TELEGRAM_HEADER_ANDROID_PX : UI.TELEGRAM_HEADER_IOS_PX;
const mobile = isMobileFullscreen
? UI.MOBILE_HEADER_HEIGHT_PX +
Math.max(safeAreaInset.top, contentSafeAreaInset.top) +
telegramHeaderHeight
: UI.MOBILE_HEADER_HEIGHT_PX;
return { mobile, desktop: UI.DESKTOP_HEADER_HEIGHT_PX };
}

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { referralNetworkApi } from '@/api/referralNetwork'; import { referralNetworkApi } from '@/api/referralNetwork';
import { useReferralNetworkStore } from '@/store/referralNetwork'; import { useReferralNetworkStore } from '@/store/referralNetwork';
import { useHeaderHeight } from '@/hooks/useHeaderHeight';
import { AdminBackButton } from '@/components/admin/AdminBackButton'; import { AdminBackButton } from '@/components/admin/AdminBackButton';
import { NetworkGraph } from './components/NetworkGraph'; import { NetworkGraph } from './components/NetworkGraph';
import { ScopeSelector } from './components/ScopeSelector'; import { ScopeSelector } from './components/ScopeSelector';
@@ -20,6 +21,8 @@ export function ReferralNetwork() {
const removeScope = useReferralNetworkStore((s) => s.removeScope); const removeScope = useReferralNetworkStore((s) => s.removeScope);
const clearScope = useReferralNetworkStore((s) => s.clearScope); const clearScope = useReferralNetworkStore((s) => s.clearScope);
const { mobile: mobileHeaderHeight } = useHeaderHeight();
const hasScope = scope.length > 0; const hasScope = scope.length > 0;
const { const {
@@ -38,14 +41,18 @@ export function ReferralNetwork() {
return createPortal( return createPortal(
<div <div
id="referral-network-container" id="referral-network-container"
className="fixed inset-x-0 bottom-0 top-16 z-40 grid grid-rows-[auto_1fr] bg-[#0a0a0f] lg:top-14" className="fixed inset-x-0 bottom-0 z-40 grid grid-rows-[auto_1fr] bg-[#0a0a0f] lg:!top-14"
style={{ top: mobileHeaderHeight }}
> >
<div className="z-20 border-b border-dark-700/50 bg-dark-900/90 backdrop-blur-md"> <div className="z-20 border-b border-dark-700/50 bg-dark-900/90 backdrop-blur-md">
<div className="flex items-center gap-2 px-3 py-2 sm:gap-3 sm:px-4 sm:py-3"> {/* Mobile: two rows — title on top, selector below */}
<AdminBackButton /> <div className="flex flex-col gap-1.5 px-3 py-2 sm:flex-row sm:items-center sm:gap-3 sm:px-4 sm:py-3">
<h1 className="shrink-0 text-sm font-bold text-dark-100 sm:text-base"> <div className="flex items-center gap-2">
{t('admin.referralNetwork.title')} <AdminBackButton />
</h1> <h1 className="shrink-0 text-sm font-bold text-dark-100 sm:text-base">
{t('admin.referralNetwork.title')}
</h1>
</div>
<ScopeSelector <ScopeSelector
value={scope} value={scope}
onAdd={addScope} onAdd={addScope}