mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
The desktop top-bar nav rendered icon-only buttons whose label only slid out on hover (max-w-0 -> group-hover:max-w-40) — mystery-meat, jumpy, hard to scan. Replace with always-visible 'icon + label' links and a single framer-motion pill (shared layoutId) that smoothly slides to the active item; compact spacing, refined active/hover states. Also delete DesktopSidebar.tsx — dead code since the cabinet's inception (never imported anywhere, only re-exported from the barrel).
297 lines
11 KiB
TypeScript
297 lines
11 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useLocation, Link } from 'react-router';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { motion } from 'framer-motion';
|
|
|
|
import { useAuthStore } from '@/store/auth';
|
|
import { useHaptic } from '@/platform';
|
|
import { useTelegramSDK } from '@/hooks/useTelegramSDK';
|
|
import { useHeaderHeight } from '@/hooks/useHeaderHeight';
|
|
import { useTheme } from '@/hooks/useTheme';
|
|
import { useBranding } from '@/hooks/useBranding';
|
|
import { useFeatureFlags } from '@/hooks/useFeatureFlags';
|
|
import { useScrollRestoration } from '@/hooks/useScrollRestoration';
|
|
import { themeColorsApi } from '@/api/themeColors';
|
|
import { isLogoPreloaded } from '@/api/branding';
|
|
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,
|
|
HomeIcon,
|
|
CreditCardIcon,
|
|
ChatIcon,
|
|
UserIcon,
|
|
UsersIcon,
|
|
ShieldIcon,
|
|
InfoIcon,
|
|
LogoutIcon,
|
|
SunIcon,
|
|
MoonIcon,
|
|
} from '@/components/icons';
|
|
|
|
import { MobileBottomNav } from './MobileBottomNav';
|
|
import { AppHeader } from './AppHeader';
|
|
import { BackgroundRenderer } from '@/components/backgrounds/BackgroundRenderer';
|
|
|
|
interface AppShellProps {
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
export function AppShell({ children }: AppShellProps) {
|
|
const { t } = useTranslation();
|
|
const location = useLocation();
|
|
const isAdmin = useAuthStore((state) => state.isAdmin);
|
|
const logout = useAuthStore((state) => state.logout);
|
|
const { isFullscreen, safeAreaInset, contentSafeAreaInset, platform, isMobile } =
|
|
useTelegramSDK();
|
|
const { mobile: headerHeight } = useHeaderHeight();
|
|
const haptic = useHaptic();
|
|
const { toggleTheme, isDark } = useTheme();
|
|
|
|
// Extracted hooks
|
|
const { appName, logoLetter, hasCustomLogo, logoUrl } = useBranding();
|
|
const { referralEnabled, wheelEnabled, hasContests, hasPolls, giftEnabled } = useFeatureFlags();
|
|
useScrollRestoration();
|
|
|
|
// Theme toggle visibility
|
|
const { data: enabledThemes } = useQuery({
|
|
queryKey: ['enabled-themes'],
|
|
queryFn: themeColorsApi.getEnabledThemes,
|
|
staleTime: 1000 * 60 * 5,
|
|
});
|
|
const canToggleTheme = enabledThemes?.dark && enabledThemes?.light;
|
|
|
|
// Only apply fullscreen UI adjustments on mobile Telegram (iOS/Android)
|
|
const isMobileFullscreen = isFullscreen && isMobile;
|
|
|
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
|
const [isKeyboardOpen, setIsKeyboardOpen] = useState(false);
|
|
|
|
// Reset keyboard state on route change — prevents bottom nav staying hidden after navigation
|
|
useEffect(() => {
|
|
setIsKeyboardOpen(false);
|
|
}, [location.pathname]);
|
|
|
|
// Keyboard detection for hiding bottom nav
|
|
useEffect(() => {
|
|
const handleFocusIn = (e: FocusEvent) => {
|
|
const target = e.target as HTMLElement;
|
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
|
setIsKeyboardOpen(true);
|
|
}
|
|
};
|
|
|
|
const handleFocusOut = (e: FocusEvent) => {
|
|
const relatedTarget = e.relatedTarget as HTMLElement | null;
|
|
if (
|
|
!relatedTarget ||
|
|
(relatedTarget.tagName !== 'INPUT' &&
|
|
relatedTarget.tagName !== 'TEXTAREA' &&
|
|
!relatedTarget.isContentEditable)
|
|
) {
|
|
setIsKeyboardOpen(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener('focusin', handleFocusIn);
|
|
document.addEventListener('focusout', handleFocusOut);
|
|
|
|
return () => {
|
|
document.removeEventListener('focusin', handleFocusIn);
|
|
document.removeEventListener('focusout', handleFocusOut);
|
|
};
|
|
}, []);
|
|
|
|
// Desktop navigation — labels always visible (no hover-reveal gimmick)
|
|
const desktopNav = [
|
|
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
|
|
{ path: '/subscriptions', label: t('nav.subscription'), icon: SubscriptionIcon },
|
|
{ path: '/balance', label: t('nav.balance'), icon: CreditCardIcon },
|
|
...(referralEnabled ? [{ path: '/referral', label: t('nav.referral'), icon: UsersIcon }] : []),
|
|
...(giftEnabled ? [{ path: '/gift', label: t('nav.gift'), icon: GiftIcon }] : []),
|
|
{ path: '/support', label: t('nav.support'), icon: ChatIcon },
|
|
{ path: '/info', label: t('nav.info'), icon: InfoIcon },
|
|
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
|
|
];
|
|
|
|
const isActive = (path: string) => {
|
|
if (path === '/') return location.pathname === '/';
|
|
return location.pathname.startsWith(path);
|
|
};
|
|
|
|
const handleNavClick = () => {
|
|
haptic.impact('light');
|
|
};
|
|
|
|
// A single elegant nav link: icon + label always visible, with a shared
|
|
// framer-motion pill that slides to the active item on navigation.
|
|
const renderNavLink = (
|
|
path: string,
|
|
label: string,
|
|
Icon: React.ComponentType<{ className?: string }>,
|
|
admin = false,
|
|
) => {
|
|
const active = admin ? location.pathname.startsWith('/admin') : isActive(path);
|
|
return (
|
|
<Link
|
|
key={path}
|
|
to={path}
|
|
onClick={handleNavClick}
|
|
aria-label={label}
|
|
className={cn(
|
|
'relative flex items-center gap-2 rounded-lg px-3 py-1.5 text-[13px] font-medium transition-colors duration-200',
|
|
active
|
|
? admin
|
|
? 'text-warning-400'
|
|
: 'text-dark-50'
|
|
: admin
|
|
? 'text-warning-500/70 hover:bg-warning-500/10 hover:text-warning-400'
|
|
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-100',
|
|
)}
|
|
>
|
|
{active && (
|
|
<motion.span
|
|
layoutId="desktop-nav-active"
|
|
className={cn(
|
|
'absolute inset-0 rounded-lg',
|
|
admin ? 'bg-warning-500/10' : 'bg-dark-800',
|
|
)}
|
|
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
|
|
/>
|
|
)}
|
|
<Icon className="relative h-[17px] w-[17px] shrink-0" />
|
|
<span className="relative whitespace-nowrap">{label}</span>
|
|
</Link>
|
|
);
|
|
};
|
|
|
|
// headerHeight comes from useHeaderHeight() — accounts for TG safe area in fullscreen
|
|
|
|
return (
|
|
<div className="min-h-viewport">
|
|
{/* Animated background renders via portal on document.body at z-index: -1 */}
|
|
<BackgroundRenderer />
|
|
|
|
{/* Global components */}
|
|
<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">
|
|
<div className="mx-auto grid h-14 max-w-6xl grid-cols-[auto_1fr_auto] items-center gap-4 px-6">
|
|
{/* Logo */}
|
|
<Link to="/" className="flex items-center gap-2.5" onClick={handleNavClick}>
|
|
<div className="relative flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-lg bg-dark-800">
|
|
<span
|
|
className={cn(
|
|
'absolute text-sm font-bold text-accent-400 transition-opacity duration-200',
|
|
hasCustomLogo && isLogoPreloaded() ? 'opacity-0' : 'opacity-100',
|
|
)}
|
|
>
|
|
{logoLetter}
|
|
</span>
|
|
{hasCustomLogo && logoUrl && (
|
|
<img
|
|
src={logoUrl}
|
|
alt={appName || 'Logo'}
|
|
className={cn(
|
|
'absolute h-full w-full object-contain transition-opacity duration-200',
|
|
isLogoPreloaded() ? 'opacity-100' : 'opacity-0',
|
|
)}
|
|
/>
|
|
)}
|
|
</div>
|
|
<span className="text-base font-semibold text-dark-100">{appName}</span>
|
|
</Link>
|
|
|
|
{/* Center Navigation */}
|
|
<nav className="flex min-w-0 items-center justify-center gap-0.5">
|
|
{desktopNav.map((item) => renderNavLink(item.path, item.label, item.icon))}
|
|
{isAdmin && (
|
|
<>
|
|
<div className="mx-1.5 h-5 w-px shrink-0 bg-dark-700/70" />
|
|
{renderNavLink('/admin', t('admin.nav.title'), ShieldIcon, true)}
|
|
</>
|
|
)}
|
|
</nav>
|
|
|
|
{/* Right side actions */}
|
|
<div className="flex items-center justify-end gap-2">
|
|
<button
|
|
onClick={() => {
|
|
haptic.impact('light');
|
|
toggleTheme();
|
|
}}
|
|
className={cn(
|
|
'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" />}
|
|
</button>
|
|
<TicketNotificationBell isAdmin={location.pathname.startsWith('/admin')} />
|
|
<LanguageSwitcher />
|
|
<button
|
|
onClick={() => {
|
|
haptic.impact('light');
|
|
logout();
|
|
}}
|
|
className="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"
|
|
title={t('nav.logout')}
|
|
>
|
|
<LogoutIcon className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Mobile Header */}
|
|
<AppHeader
|
|
mobileMenuOpen={mobileMenuOpen}
|
|
setMobileMenuOpen={setMobileMenuOpen}
|
|
onCommandPaletteOpen={() => {}}
|
|
headerHeight={headerHeight}
|
|
isFullscreen={isMobileFullscreen}
|
|
safeAreaInset={safeAreaInset}
|
|
contentSafeAreaInset={contentSafeAreaInset}
|
|
telegramPlatform={platform}
|
|
wheelEnabled={wheelEnabled}
|
|
referralEnabled={referralEnabled}
|
|
hasContests={hasContests}
|
|
hasPolls={hasPolls}
|
|
giftEnabled={giftEnabled}
|
|
/>
|
|
|
|
{/* Desktop spacer */}
|
|
<div className="hidden h-14 lg:block" />
|
|
|
|
{/* Mobile spacer */}
|
|
<div className="lg:hidden" style={{ height: headerHeight }} />
|
|
|
|
{/* Main content */}
|
|
<main className="mx-auto max-w-6xl px-4 py-6 pb-28 lg:px-6 lg:pb-8">{children}</main>
|
|
|
|
{/* Mobile Bottom Navigation */}
|
|
<MobileBottomNav
|
|
isKeyboardOpen={isKeyboardOpen}
|
|
referralEnabled={referralEnabled}
|
|
wheelEnabled={wheelEnabled}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|