feat: Linear-style UI redesign with improved mobile experience

Major changes:
- Redesign cabinet with Linear-style components and top navigation
- Replace detail modals with dedicated pages (users, broadcasts, email templates)
- Add email channel support for broadcasts
- Remove pull-to-refresh, improve drag-and-drop on touch devices
- Fix Telegram Mini App: fullscreen, back button, scroll restoration
- Unify admin pages color scheme to design system
- Mobile-first improvements: horizontal tabs for settings, better touch targets
This commit is contained in:
c0mrade
2026-01-31 22:06:36 +03:00
parent 929634aac4
commit b953ee0b8c
108 changed files with 11711 additions and 4542 deletions

View File

@@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { authApi } from '../api/auth';
import { useAuthStore } from '../store/auth';
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
import { useBackButton } from '@/platform';
// Icons
const CloseIcon = () => (
@@ -55,7 +56,7 @@ export default function ChangeEmailModal({ onClose, currentEmail }: ChangeEmailM
const { t } = useTranslation();
const queryClient = useQueryClient();
const { setUser } = useAuthStore();
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp();
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp();
const isMobileScreen = useIsMobile();
const emailInputRef = useRef<HTMLInputElement>(null);
@@ -87,16 +88,8 @@ export default function ChangeEmailModal({ onClose, currentEmail }: ChangeEmailM
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleClose]);
// Telegram back button
useEffect(() => {
if (!webApp?.BackButton) return;
webApp.BackButton.show();
webApp.BackButton.onClick(handleClose);
return () => {
webApp.BackButton.offClick(handleClose);
webApp.BackButton.hide();
};
}, [webApp, handleClose]);
// Telegram back button - using platform hook
useBackButton(handleClose);
// Scroll lock
useEffect(() => {

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { subscriptionApi } from '../api/subscription';
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
import { useBackButton, useHaptic } from '@/platform';
import type { AppInfo, AppConfig, LocalizedText } from '../types';
interface ConnectionModalProps {
@@ -156,14 +157,18 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
const [showAppSelector, setShowAppSelector] = useState(false);
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
const { isTelegramWebApp, isFullscreen, safeAreaInset, contentSafeAreaInset, webApp } =
const { isTelegramWebApp, isFullscreen, safeAreaInset, contentSafeAreaInset } =
useTelegramWebApp();
const { impact: hapticImpact } = useHaptic();
const isMobileScreen = useIsMobile();
const isMobile = isMobileScreen;
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Ref для хранения актуального обработчика BackButton (фикс мигания)
const backButtonHandlerRef = useRef<() => void>(() => {});
// Ref for haptic to avoid recreating handleBackButton
const hapticRef = useRef(hapticImpact);
hapticRef.current = hapticImpact;
// Prevent scroll events from bubbling to parent/Telegram
const handleScrollContainerWheel = useCallback((e: React.WheelEvent) => {
@@ -237,23 +242,13 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
backButtonHandlerRef.current = showAppSelector ? handleBack : handleClose;
}, [showAppSelector, handleBack, handleClose]);
// Управление BackButton — эффект запускается только при mount/unmount
// Используем стабильный обработчик через ref, чтобы избежать мигания
useEffect(() => {
if (!webApp?.BackButton) return;
// BackButton using platform hook - always close/back, ref provides current handler
const handleBackButton = useCallback(() => {
hapticRef.current('light');
backButtonHandlerRef.current();
}, []);
const stableHandler = () => {
backButtonHandlerRef.current();
};
webApp.BackButton.show();
webApp.BackButton.onClick(stableHandler);
return () => {
webApp.BackButton.offClick(stableHandler);
webApp.BackButton.hide();
};
}, [webApp]); // Только webApp в зависимостях!
useBackButton(handleBackButton);
useEffect(() => {
document.body.style.overflow = 'hidden';
@@ -446,12 +441,14 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
return (
<Wrapper>
<div className="flex items-center gap-3 border-b border-dark-800 p-4">
<button
onClick={handleBack}
className="-ml-2 rounded-xl p-2 text-dark-300 hover:bg-dark-800"
>
<BackIcon />
</button>
{!isTelegramWebApp && (
<button
onClick={handleBack}
className="-ml-2 rounded-xl p-2 text-dark-300 hover:bg-dark-800"
>
<BackIcon />
</button>
)}
<h2 className="text-lg font-bold text-dark-100">
{t('subscription.connection.selectPlatform')}
</h2>
@@ -518,12 +515,14 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
return (
<Wrapper>
<div className="flex items-center gap-3 border-b border-dark-800 p-4">
<button
onClick={handleBack}
className="-ml-2 rounded-xl p-2 text-dark-300 hover:bg-dark-800"
>
<BackIcon />
</button>
{!isTelegramWebApp && (
<button
onClick={handleBack}
className="-ml-2 rounded-xl p-2 text-dark-300 hover:bg-dark-800"
>
<BackIcon />
</button>
)}
<div className="flex-1">
<h2 className="text-lg font-bold text-dark-100">
{platformNames[selectedPlatform] || selectedPlatform}
@@ -607,12 +606,14 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
<div className="border-b border-dark-800 p-4">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-bold text-dark-100">{t('subscription.connection.title')}</h2>
<button
onClick={handleClose}
className="-mr-2 rounded-xl p-2 text-dark-400 hover:bg-dark-800"
>
<CloseIcon />
</button>
{!isTelegramWebApp && (
<button
onClick={handleClose}
className="-mr-2 rounded-xl p-2 text-dark-400 hover:bg-dark-800"
>
<CloseIcon />
</button>
)}
</div>
<button
onClick={() => setShowAppSelector(true)}

View File

@@ -41,7 +41,11 @@ export default function LanguageSwitcher() {
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-1.5 rounded-xl border border-dark-700/50 bg-dark-800/50 px-2.5 py-2 text-sm transition-all hover:border-dark-600 hover:bg-dark-700"
className={`flex items-center gap-1.5 rounded-xl border px-2.5 py-2 text-sm transition-all ${
isOpen
? 'border-dark-600 bg-dark-700'
: 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600 hover:bg-dark-700'
}`}
aria-label="Change language"
>
<span>{currentLang.flag}</span>

View File

@@ -10,6 +10,7 @@ import { useNavigate } from 'react-router-dom';
import { useSuccessNotification } from '../store/successNotification';
import { useCurrency } from '../hooks/useCurrency';
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
import { useBackButton, useHaptic } from '@/platform';
// Icons
const CheckCircleIcon = () => (
@@ -79,7 +80,8 @@ export default function SuccessNotificationModal() {
const navigate = useNavigate();
const { isOpen, data, hide } = useSuccessNotification();
const { formatAmount, currencySymbol } = useCurrency();
const { webApp, safeAreaInset, contentSafeAreaInset, isTelegramWebApp } = useTelegramWebApp();
const { safeAreaInset, contentSafeAreaInset, isTelegramWebApp } = useTelegramWebApp();
const haptic = useHaptic();
const safeBottom = isTelegramWebApp
? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom)
@@ -104,18 +106,15 @@ export default function SuccessNotificationModal() {
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, handleClose]);
// Telegram back button
// Telegram back button - using platform hook
useBackButton(isOpen ? handleClose : null);
// Haptic feedback on open
useEffect(() => {
if (!isOpen || !webApp?.BackButton) return;
webApp.BackButton.show();
webApp.BackButton.onClick(handleClose);
return () => {
webApp.BackButton.offClick(handleClose);
webApp.BackButton.hide();
};
}, [isOpen, webApp, handleClose]);
if (isOpen) {
haptic.notification('success');
}
}, [isOpen, haptic]);
// Scroll lock
useEffect(() => {

View File

@@ -241,7 +241,11 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
{/* Bell button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="relative rounded-xl border border-dark-700/50 bg-dark-800/50 p-2 text-dark-400 transition-all duration-200 hover:bg-dark-700 hover:text-accent-400"
className={`relative rounded-xl border p-2 transition-all duration-200 ${
isOpen
? 'border-dark-600 bg-dark-700 text-accent-400'
: 'border-dark-700/50 bg-dark-800/50 text-dark-400 hover:bg-dark-700 hover:text-accent-400'
}`}
title={t('notifications.ticketNotifications', 'Ticket notifications')}
>
<BellIcon />

View File

@@ -7,6 +7,7 @@ import { useCurrency } from '../hooks/useCurrency';
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit';
import { useCloseOnSuccessNotification } from '../store/successNotification';
import { useBackButton, useMainButton, useHaptic, usePlatform } from '@/platform';
import type { PaymentMethod } from '../types';
import BentoCard from './ui/BentoCard';
@@ -116,7 +117,9 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
const { t } = useTranslation();
const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } =
useCurrency();
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp();
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp();
const { openInvoice } = usePlatform();
const haptic = useHaptic();
const inputRef = useRef<HTMLInputElement>(null);
const isMobileScreen = useIsMobile();
@@ -160,16 +163,8 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleClose]);
// Telegram back button (Android)
useEffect(() => {
if (!webApp?.BackButton) return;
webApp.BackButton.show();
webApp.BackButton.onClick(handleClose);
return () => {
webApp.BackButton.offClick(handleClose);
webApp.BackButton.hide();
};
}, [webApp, handleClose]);
// Telegram back button - using platform hook
useBackButton(handleClose);
// Scroll lock
useEffect(() => {
@@ -205,35 +200,29 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
const starsPaymentMutation = useMutation({
mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks),
onSuccess: (data) => {
const webApp = window.Telegram?.WebApp;
onSuccess: async (data) => {
if (!data.invoice_url) {
setError(t('balance.errors.noPaymentLink'));
return;
}
// openInvoice requires WebApp version 6.1+
const supportsInvoice =
webApp?.openInvoice && webApp?.isVersionAtLeast && webApp.isVersionAtLeast('6.1');
if (supportsInvoice) {
try {
webApp.openInvoice(data.invoice_url, (status) => {
if (status === 'paid') {
setError(null);
onClose();
} else if (status === 'failed') {
setError(t('wheel.starsPaymentFailed'));
}
});
} catch (e) {
setError(t('balance.errors.generic', { details: String(e) }));
try {
// Use platform-agnostic invoice opening
const status = await openInvoice(data.invoice_url);
if (status === 'paid') {
haptic.notification('success');
setError(null);
onClose();
} else if (status === 'failed') {
haptic.notification('error');
setError(t('wheel.starsPaymentFailed'));
}
} else {
// Fallback: open invoice URL in Telegram (browser or unsupported WebApp version)
window.open(data.invoice_url, '_blank', 'noopener,noreferrer');
onClose();
// 'pending' and 'cancelled' just close without action
} catch (e) {
setError(t('balance.errors.generic', { details: String(e) }));
}
},
onError: (err: unknown) => {
haptic.notification('error');
const axiosError = err as { response?: { data?: { detail?: string }; status?: number } };
setError(axiosError?.response?.data?.detail || t('balance.errors.invoiceFailed'));
},
@@ -313,6 +302,24 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
: convertAmount(rub).toFixed(currencyDecimals);
const isPending = topUpMutation.isPending || starsPaymentMutation.isPending;
// Check if form is valid for MainButton
const amountNum = parseFloat(amount);
const amountRubles = !isNaN(amountNum) && amountNum > 0 ? convertToRub(amountNum) : 0;
const isFormValid =
!isPending &&
!paymentUrl &&
amountRubles >= minRubles &&
amountRubles <= maxRubles &&
(!hasOptions || !!selectedOption);
// Telegram MainButton integration - shows "Top Up" action
useMainButton(isFormValid && !isInputFocused ? handleSubmit : null, {
text: t('balance.topUp'),
isLoading: isPending,
isActive: isFormValid,
visible: !paymentUrl && isMobileScreen,
});
const handleCopyUrl = async () => {
if (!paymentUrl) return;
try {

View File

@@ -0,0 +1,33 @@
import { Link } from 'react-router-dom';
import { usePlatform } from '@/platform';
import { BackIcon } from './icons';
interface AdminBackButtonProps {
to?: string;
className?: string;
}
/**
* Back button for admin pages.
* Hidden in Telegram Mini App since native back button is used instead.
*/
export function AdminBackButton({ to = '/admin', className }: AdminBackButtonProps) {
const { platform } = usePlatform();
// In Telegram Mini App, we use native back button
if (platform === 'telegram') {
return null;
}
return (
<Link
to={to}
className={
className ||
'flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600'
}
>
<BackIcon />
</Link>
);
}

View File

@@ -0,0 +1,61 @@
import { motion } from 'framer-motion';
import type { Transition, Variants } from 'framer-motion';
import type { ReactNode } from 'react';
/**
* Centralized animation config for all admin pages.
* Change animations here to affect all admin pages at once.
*/
export const adminPageTransition: Transition = {
duration: 0.15,
ease: [0.16, 1, 0.3, 1] as const, // easeOutExpo
};
export const adminPageVariants: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
};
interface AdminLayoutProps {
children: ReactNode;
className?: string;
}
/**
* AdminLayout - wrapper for all admin pages with consistent animations.
* Use this to wrap admin page content for unified transitions.
*/
export function AdminLayout({ children, className }: AdminLayoutProps) {
return (
<motion.div
variants={adminPageVariants}
initial="initial"
animate="animate"
exit="exit"
transition={adminPageTransition}
className={className}
>
{children}
</motion.div>
);
}
/**
* Admin content animations for lists and cards.
* Use with staggered children.
*/
export const adminStaggerContainer: Variants = {
initial: {},
animate: {
transition: {
staggerChildren: 0.03,
},
},
};
export const adminStaggerItem: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
};

View File

@@ -0,0 +1,78 @@
import { useTranslation } from 'react-i18next';
import { useRef, useEffect } from 'react';
import { MENU_SECTIONS } from './constants';
import { StarIcon } from './icons';
interface SettingsMobileTabsProps {
activeSection: string;
setActiveSection: (section: string) => void;
favoritesCount: number;
}
export function SettingsMobileTabs({
activeSection,
setActiveSection,
favoritesCount,
}: SettingsMobileTabsProps) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const activeRef = useRef<HTMLButtonElement>(null);
// Scroll active tab into view
useEffect(() => {
if (activeRef.current && scrollRef.current) {
const container = scrollRef.current;
const activeEl = activeRef.current;
const containerRect = container.getBoundingClientRect();
const activeRect = activeEl.getBoundingClientRect();
// Check if active element is not fully visible
if (activeRect.left < containerRect.left || activeRect.right > containerRect.right) {
activeEl.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
}
}
}, [activeSection]);
// Flatten all items from all sections
const allItems = MENU_SECTIONS.flatMap((section) => section.items);
return (
<div
ref={scrollRef}
className="scrollbar-hide flex gap-2 overflow-x-auto px-3 py-3"
style={{ WebkitOverflowScrolling: 'touch' }}
>
{allItems.map((item) => {
const isActive = activeSection === item.id;
const hasIcon = item.iconType === 'star';
return (
<button
key={item.id}
ref={isActive ? activeRef : null}
onClick={() => setActiveSection(item.id)}
className={`flex shrink-0 items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${
isActive
? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/30'
: 'bg-dark-800/50 text-dark-400 active:bg-dark-700'
}`}
>
{hasIcon && <StarIcon filled={isActive && item.id === 'favorites'} />}
<span className="whitespace-nowrap">{t(`admin.settings.${item.id}`)}</span>
{item.id === 'favorites' && favoritesCount > 0 && (
<span
className={`rounded-full px-1.5 py-0.5 text-xs ${
isActive
? 'bg-accent-500/20 text-accent-400'
: 'bg-warning-500/20 text-warning-400'
}`}
>
{favoritesCount}
</span>
)}
</button>
);
})}
</div>
);
}

View File

@@ -1,6 +1,5 @@
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { BackIcon, StarIcon, CloseIcon, MENU_SECTIONS } from './index';
import { AdminBackButton, StarIcon, CloseIcon, MENU_SECTIONS } from './index';
interface SettingsSidebarProps {
activeSection: string;
@@ -26,12 +25,7 @@ export function SettingsSidebar({
{/* Header */}
<div className="border-b border-dark-700/50 p-4">
<div className="flex items-center gap-3">
<Link
to="/admin"
className="rounded-xl bg-dark-800 p-2 transition-colors hover:bg-dark-700"
>
<BackIcon />
</Link>
<AdminBackButton className="rounded-xl bg-dark-800 p-2 transition-colors hover:bg-dark-700" />
<h1 className="text-lg font-bold text-dark-100">{t('admin.settings.title')}</h1>
<button
onClick={() => setMobileMenuOpen(false)}

View File

@@ -1,4 +1,6 @@
// Components
export * from './AdminBackButton';
export * from './AdminLayout';
export * from './icons';
export * from './Toggle';
export * from './SettingInput';
@@ -9,6 +11,7 @@ export * from './ThemeTab';
export * from './FavoritesTab';
export * from './SettingsTab';
export * from './SettingsSidebar';
export * from './SettingsMobileTabs';
export * from './SettingsSearch';
// Constants and utils

View File

@@ -0,0 +1,175 @@
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
import { motion, type HTMLMotionProps } from 'framer-motion';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { usePlatform } from '@/platform';
import { buttonTap, buttonHover, springTransition } from '@/components/motion/transitions';
const cardVariants = cva(
[
'relative overflow-hidden',
'border border-dark-700/40 bg-dark-900/70',
'rounded-linear-lg',
'transition-all duration-200',
// GPU acceleration
'transform-gpu',
// Glass border inset
'shadow-[inset_0_1px_0_0_rgba(255,255,255,0.05)]',
],
{
variants: {
size: {
sm: 'p-3',
md: 'p-4',
lg: 'p-5 sm:p-6',
xl: 'p-6 sm:p-8',
},
variant: {
default: '',
glass: 'backdrop-blur-linear bg-dark-900/50',
solid: 'bg-dark-900',
outline: 'bg-transparent',
},
interactive: {
true: [
'cursor-pointer',
'hover:border-dark-600/50 hover:bg-dark-800/60',
'active:scale-[0.98]',
],
false: '',
},
glow: {
true: 'hover:border-accent-500/30 hover:shadow-glow',
false: '',
},
},
defaultVariants: {
size: 'lg',
variant: 'default',
interactive: false,
glow: false,
},
},
);
export interface CardProps
extends Omit<HTMLMotionProps<'div'>, 'children'>, VariantProps<typeof cardVariants> {
children: ReactNode;
asChild?: boolean;
haptic?: boolean;
}
export const Card = forwardRef<HTMLDivElement, CardProps>(
(
{
children,
className,
size,
variant,
interactive,
glow,
asChild = false,
haptic: enableHaptic = true,
onClick,
...props
},
ref,
) => {
const { haptic } = usePlatform();
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (interactive && enableHaptic) {
haptic.impact('light');
}
onClick?.(e);
};
const classes = cn(cardVariants({ size, variant, interactive, glow }), className);
if (asChild) {
return (
<Slot ref={ref} className={classes}>
{children}
</Slot>
);
}
if (interactive) {
return (
<motion.div
ref={ref}
className={classes}
onClick={handleClick}
whileHover={buttonHover}
whileTap={buttonTap}
transition={springTransition}
{...props}
>
{children}
</motion.div>
);
}
return (
<motion.div ref={ref} className={classes} {...props}>
{children}
</motion.div>
);
},
);
Card.displayName = 'Card';
// Card Header
export type CardHeaderProps = HTMLAttributes<HTMLDivElement>;
export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5', className)} {...props} />
),
);
CardHeader.displayName = 'CardHeader';
// Card Title
export type CardTitleProps = HTMLAttributes<HTMLHeadingElement>;
export const CardTitle = forwardRef<HTMLHeadingElement, CardTitleProps>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn('text-lg font-semibold text-dark-100', className)} {...props} />
),
);
CardTitle.displayName = 'CardTitle';
// Card Description
export type CardDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
export const CardDescription = forwardRef<HTMLParagraphElement, CardDescriptionProps>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-dark-400', className)} {...props} />
),
);
CardDescription.displayName = 'CardDescription';
// Card Content
export type CardContentProps = HTMLAttributes<HTMLDivElement>;
export const CardContent = forwardRef<HTMLDivElement, CardContentProps>(
({ className, ...props }, ref) => <div ref={ref} className={cn('pt-4', className)} {...props} />,
);
CardContent.displayName = 'CardContent';
// Card Footer
export type CardFooterProps = HTMLAttributes<HTMLDivElement>;
export const CardFooter = forwardRef<HTMLDivElement, CardFooterProps>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center pt-4', className)} {...props} />
),
);
CardFooter.displayName = 'CardFooter';

View File

@@ -0,0 +1,14 @@
export {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
type CardProps,
type CardHeaderProps,
type CardTitleProps,
type CardDescriptionProps,
type CardContentProps,
type CardFooterProps,
} from './Card';

View File

@@ -0,0 +1,56 @@
import { forwardRef, type ReactNode } from 'react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import { Button, type ButtonProps } from '@/components/primitives/Button';
import { fadeIn, fadeInTransition } from '@/components/motion/transitions';
export interface EmptyStateProps {
icon?: ReactNode;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
variant?: ButtonProps['variant'];
};
className?: string;
}
export const EmptyState = forwardRef<HTMLDivElement, EmptyStateProps>(
({ icon, title, description, action, className }, ref) => {
return (
<motion.div
ref={ref}
className={cn('flex flex-col items-center justify-center py-12 text-center', className)}
variants={fadeIn}
initial="initial"
animate="animate"
transition={fadeInTransition}
>
{/* Icon */}
{icon && (
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-dark-800/50 text-dark-400">
{icon}
</div>
)}
{/* Title */}
<h3 className="text-lg font-semibold text-dark-100">{title}</h3>
{/* Description */}
{description && <p className="mt-2 max-w-sm text-sm text-dark-400">{description}</p>}
{/* Action */}
{action && (
<div className="mt-6">
<Button variant={action.variant || 'primary'} onClick={action.onClick}>
{action.label}
</Button>
</div>
)}
</motion.div>
);
},
);
EmptyState.displayName = 'EmptyState';

View File

@@ -0,0 +1 @@
export { EmptyState, type EmptyStateProps } from './EmptyState';

View File

@@ -0,0 +1,83 @@
import { forwardRef, type ReactNode } from 'react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import { Card, type CardProps } from '../Card';
import { slideUp, slideUpTransition } from '@/components/motion/transitions';
export interface StatCardProps extends Omit<CardProps, 'children'> {
label: string;
value: ReactNode;
icon?: ReactNode;
change?: {
value: number;
label?: string;
};
trend?: 'up' | 'down' | 'neutral';
loading?: boolean;
}
export const StatCard = forwardRef<HTMLDivElement, StatCardProps>(
(
{ label, value, icon, change, trend = 'neutral', loading = false, className, ...props },
ref,
) => {
const trendColors = {
up: 'text-success-400',
down: 'text-error-400',
neutral: 'text-dark-400',
};
const trendIcon = {
up: '↑',
down: '↓',
neutral: '→',
};
return (
<Card ref={ref} className={cn('relative', className)} size="md" {...props}>
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
{/* Label */}
<p className="truncate text-sm font-medium text-dark-400">{label}</p>
{/* Value */}
{loading ? (
<div className="mt-2 h-8 w-24 animate-pulse rounded bg-dark-800" />
) : (
<motion.p
className="mt-1 text-2xl font-bold text-dark-100 sm:text-3xl"
variants={slideUp}
initial="initial"
animate="animate"
transition={slideUpTransition}
>
{value}
</motion.p>
)}
{/* Change indicator */}
{change && !loading && (
<div className={cn('mt-2 flex items-center gap-1 text-sm', trendColors[trend])}>
<span>{trendIcon[trend]}</span>
<span>
{change.value > 0 ? '+' : ''}
{change.value}%
</span>
{change.label && <span className="text-dark-500">{change.label}</span>}
</div>
)}
</div>
{/* Icon */}
{icon && (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-linear bg-dark-800/80 text-dark-400">
{icon}
</div>
)}
</div>
</Card>
);
},
);
StatCard.displayName = 'StatCard';

View File

@@ -0,0 +1 @@
export { StatCard, type StatCardProps } from './StatCard';

View File

@@ -0,0 +1,3 @@
export * from './Card';
export * from './StatCard';
export * from './EmptyState';

View File

@@ -0,0 +1,427 @@
import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { useState, useEffect } from 'react';
import { useAuthStore } from '@/store/auth';
import { useTheme } from '@/hooks/useTheme';
import { usePlatform } from '@/platform';
import {
brandingApi,
getCachedBranding,
setCachedBranding,
preloadLogo,
isLogoPreloaded,
} from '@/api/branding';
import { themeColorsApi } from '@/api/themeColors';
import { promoApi } from '@/api/promo';
import { cn } from '@/lib/utils';
import LanguageSwitcher from '@/components/LanguageSwitcher';
import PromoDiscountBadge from '@/components/PromoDiscountBadge';
import TicketNotificationBell from '@/components/TicketNotificationBell';
// Icons
import {
HomeIcon,
SubscriptionIcon,
WalletIcon,
UsersIcon,
ChatIcon,
UserIcon,
LogoutIcon,
GamepadIcon,
ClipboardIcon,
InfoIcon,
CogIcon,
WheelIcon,
MenuIcon,
CloseIcon,
SunIcon,
MoonIcon,
SearchIcon,
} from './icons';
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet';
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V';
interface AppHeaderProps {
mobileMenuOpen: boolean;
setMobileMenuOpen: (open: boolean) => void;
onCommandPaletteOpen: () => void;
headerHeight: number;
isFullscreen: boolean;
safeAreaInset: { top: number; bottom: number };
contentSafeAreaInset: { top: number; bottom: number };
wheelEnabled?: boolean;
referralEnabled?: boolean;
hasContests?: boolean;
hasPolls?: boolean;
}
export function AppHeader({
mobileMenuOpen,
setMobileMenuOpen,
onCommandPaletteOpen,
headerHeight,
isFullscreen,
safeAreaInset,
contentSafeAreaInset,
wheelEnabled,
referralEnabled,
hasContests,
hasPolls,
}: AppHeaderProps) {
const { t } = useTranslation();
const location = useLocation();
const { user, logout, isAdmin, isAuthenticated } = useAuthStore();
const { toggleTheme, isDark } = useTheme();
const { haptic, platform } = usePlatform();
const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null);
const [logoLoaded, setLogoLoaded] = useState(() => isLogoPreloaded());
// Branding
const { data: branding } = useQuery({
queryKey: ['branding'],
queryFn: async () => {
const data = await brandingApi.getBranding();
setCachedBranding(data);
preloadLogo(data);
return data;
},
initialData: getCachedBranding() ?? undefined,
staleTime: 60000,
refetchOnWindowFocus: true,
retry: 1,
});
const appName = branding ? branding.name : FALLBACK_NAME;
const logoLetter = branding?.logo_letter || FALLBACK_LOGO;
const hasCustomLogo = branding?.has_custom_logo || false;
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null;
// Theme toggle visibility
const { data: enabledThemes } = useQuery({
queryKey: ['enabled-themes'],
queryFn: themeColorsApi.getEnabledThemes,
staleTime: 1000 * 60 * 5,
});
const canToggle = enabledThemes?.dark && enabledThemes?.light;
// Promo active check
const { data: activeDiscount } = useQuery({
queryKey: ['active-discount'],
queryFn: promoApi.getActiveDiscount,
enabled: isAuthenticated,
staleTime: 30000,
});
const isPromoActive = activeDiscount?.is_active && activeDiscount?.discount_percent;
// Get user photo from Telegram
useEffect(() => {
try {
const tg = (
window as unknown as {
Telegram?: { WebApp?: { initDataUnsafe?: { user?: { photo_url?: string } } } };
}
).Telegram?.WebApp;
const photoUrl = tg?.initDataUnsafe?.user?.photo_url;
if (photoUrl) {
setUserPhotoUrl(photoUrl);
}
} catch (e) {
console.warn('Failed to get Telegram user photo:', e);
}
}, []);
// Lock scroll when menu is open (works in iframe/Telegram Mini App)
useEffect(() => {
if (!mobileMenuOpen) return;
const preventDefault = (e: TouchEvent) => {
// Allow scrolling inside menu content
const target = e.target as HTMLElement;
if (target.closest('.mobile-menu-content')) return;
e.preventDefault();
};
document.addEventListener('touchmove', preventDefault, { passive: false });
document.body.style.overflow = 'hidden';
document.documentElement.style.overflow = 'hidden';
return () => {
document.removeEventListener('touchmove', preventDefault);
document.body.style.overflow = '';
document.documentElement.style.overflow = '';
};
}, [mobileMenuOpen]);
const isActive = (path: string) => location.pathname === path;
const isAdminActive = () => location.pathname.startsWith('/admin');
const navItems = [
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
{ path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon },
{ path: '/balance', label: t('nav.balance'), icon: WalletIcon },
...(referralEnabled ? [{ path: '/referral', label: t('nav.referral'), icon: UsersIcon }] : []),
{ path: '/support', label: t('nav.support'), icon: ChatIcon },
...(hasContests ? [{ path: '/contests', label: t('nav.contests'), icon: GamepadIcon }] : []),
...(hasPolls ? [{ path: '/polls', label: t('nav.polls'), icon: ClipboardIcon }] : []),
...(wheelEnabled ? [{ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon }] : []),
{ path: '/info', label: t('nav.info'), icon: InfoIcon },
];
return (
<>
{/* Header - only on mobile */}
<header
className="glass fixed left-0 right-0 top-0 z-50 shadow-lg shadow-black/10 lg:hidden"
style={{
paddingTop: isFullscreen
? `${Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px`
: undefined,
}}
>
<div
className="mx-auto w-full px-4"
onClick={() => mobileMenuOpen && setMobileMenuOpen(false)}
>
<div className="flex h-16 items-center justify-between">
{/* Logo */}
<Link
to="/"
onClick={() => setMobileMenuOpen(false)}
className={cn('flex flex-shrink-0 items-center gap-2.5', !appName && 'mr-4')}
>
<div className="relative flex h-10 w-10 flex-shrink-0 items-center justify-center overflow-hidden rounded-linear-lg border border-dark-700/50 bg-dark-800/80 shadow-md">
<span
className={cn(
'absolute text-lg font-bold text-accent-400 transition-opacity duration-200',
hasCustomLogo && logoLoaded ? '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',
logoLoaded ? 'opacity-100' : 'opacity-0',
)}
onLoad={() => setLogoLoaded(true)}
/>
)}
</div>
{appName && (
<span className="whitespace-nowrap text-base font-semibold text-dark-100">
{appName}
</span>
)}
</Link>
{/* Right side */}
<div className="flex items-center gap-1.5">
{/* Command palette trigger (web only) */}
{platform !== 'telegram' && (
<button
onClick={() => {
haptic.impact('light');
onCommandPaletteOpen();
}}
className="btn-icon hidden sm:flex"
title="Search (⌘K)"
>
<SearchIcon className="h-5 w-5" />
</button>
)}
{/* Theme toggle */}
{canToggle && (
<button
onClick={() => {
haptic.impact('light');
toggleTheme();
setMobileMenuOpen(false);
}}
className="relative rounded-linear-lg border border-dark-700/50 bg-dark-800/50 p-2 text-dark-400 transition-all duration-200 hover:bg-dark-700 hover:text-accent-400"
title={isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'}
>
<div className="relative h-5 w-5">
<div
className={cn(
'absolute inset-0 transition-all duration-300',
isDark ? 'rotate-0 opacity-100' : 'rotate-90 opacity-0',
)}
>
<MoonIcon className="h-5 w-5" />
</div>
<div
className={cn(
'absolute inset-0 transition-all duration-300',
isDark ? '-rotate-90 opacity-0' : 'rotate-0 opacity-100',
)}
>
<SunIcon className="h-5 w-5" />
</div>
</div>
</button>
)}
<div onClick={() => setMobileMenuOpen(false)}>
<PromoDiscountBadge />
</div>
<div onClick={() => setMobileMenuOpen(false)}>
<TicketNotificationBell isAdmin={isAdminActive()} />
</div>
<div
className={isPromoActive ? 'hidden sm:block' : ''}
onClick={() => setMobileMenuOpen(false)}
>
<LanguageSwitcher />
</div>
{/* Mobile menu button */}
<button
onClick={(e) => {
e.stopPropagation();
haptic.impact('light');
setMobileMenuOpen(!mobileMenuOpen);
}}
className={`rounded-xl p-2.5 transition-all duration-200 ${
mobileMenuOpen
? 'bg-dark-700 text-dark-100'
: 'text-dark-400 hover:bg-dark-800 hover:text-dark-100'
}`}
aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
aria-expanded={mobileMenuOpen}
>
{mobileMenuOpen ? (
<CloseIcon className="h-6 w-6" />
) : (
<MenuIcon className="h-6 w-6" />
)}
</button>
</div>
</div>
</div>
</header>
{/* Mobile menu overlay */}
{mobileMenuOpen && (
<div
className="fixed inset-x-0 bottom-0 z-40 animate-fade-in lg:hidden"
style={{ top: headerHeight }}
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={() => setMobileMenuOpen(false)}
/>
{/* Menu content */}
<div
className="mobile-menu-content absolute inset-x-0 bottom-0 top-0 overflow-y-auto overscroll-contain border-t border-dark-800/50 bg-dark-900 pb-[calc(5rem+env(safe-area-inset-bottom,0px))]"
style={{ WebkitOverflowScrolling: 'touch' }}
>
<div className="mx-auto max-w-6xl px-4 py-4">
{/* User info */}
<div className="mb-4 flex items-center justify-between border-b border-dark-800/50 pb-4">
<div className="flex items-center gap-3">
{userPhotoUrl ? (
<img
src={userPhotoUrl}
alt="Avatar"
className="h-10 w-10 rounded-full object-cover"
onError={(e) => {
e.currentTarget.style.display = 'none';
e.currentTarget.nextElementSibling?.classList.remove('hidden');
}}
/>
) : null}
<div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-full bg-dark-700',
userPhotoUrl ? 'hidden' : '',
)}
>
<UserIcon className="h-5 w-5" />
</div>
<div>
<div className="text-sm font-medium text-dark-100">
{user?.first_name || user?.username}
</div>
<div className="text-xs text-dark-500">
@{user?.username || `ID: ${user?.telegram_id}`}
</div>
</div>
</div>
{isPromoActive && <LanguageSwitcher />}
</div>
{/* Nav items */}
<nav className="space-y-1">
{navItems.map((item) => (
<Link
key={item.path}
to={item.path}
onClick={() => setMobileMenuOpen(false)}
className={isActive(item.path) ? 'nav-item-active' : 'nav-item'}
>
<item.icon className="h-5 w-5" />
{item.label}
</Link>
))}
{isAdmin && (
<>
<div className="divider my-3" />
<div className="px-4 py-1 text-xs font-medium uppercase tracking-wider text-dark-500">
{t('admin.nav.title')}
</div>
<Link
to="/admin"
onClick={() => setMobileMenuOpen(false)}
className={cn(
'nav-item',
isAdminActive()
? 'bg-warning-500/10 text-warning-400'
: 'text-warning-500/70',
)}
>
<CogIcon className="h-5 w-5" />
{t('admin.nav.title')}
</Link>
</>
)}
<div className="divider my-3" />
<Link
to="/profile"
onClick={() => setMobileMenuOpen(false)}
className={isActive('/profile') ? 'nav-item-active' : 'nav-item'}
>
<UserIcon className="h-5 w-5" />
{t('nav.profile')}
</Link>
<button
onClick={() => {
setMobileMenuOpen(false);
logout();
}}
className="nav-item w-full text-error-400"
>
<LogoutIcon className="h-5 w-5" />
{t('nav.logout')}
</button>
</nav>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,517 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { useLocation, useNavigate, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'framer-motion';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/store/auth';
import { useBackButton, useHaptic } from '@/platform';
import { useTelegramWebApp } from '@/hooks/useTelegramWebApp';
import { referralApi } from '@/api/referral';
import { wheelApi } from '@/api/wheel';
import { contestsApi } from '@/api/contests';
import { pollsApi } from '@/api/polls';
import {
brandingApi,
getCachedBranding,
setCachedBranding,
preloadLogo,
isLogoPreloaded,
} from '@/api/branding';
import { setCachedFullscreenEnabled, isTelegramMobile } from '@/hooks/useTelegramWebApp';
import { cn } from '@/lib/utils';
import WebSocketNotifications from '@/components/WebSocketNotifications';
import SuccessNotificationModal from '@/components/SuccessNotificationModal';
import LanguageSwitcher from '@/components/LanguageSwitcher';
import TicketNotificationBell from '@/components/TicketNotificationBell';
import { MobileBottomNav } from './MobileBottomNav';
import { AppHeader } from './AppHeader';
import { slideUp, slideUpTransition } from '@/components/motion/transitions';
// Desktop nav icons
const HomeIcon = ({ className }: { className?: string }) => (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
/>
</svg>
);
const CreditCardIcon = ({ className }: { className?: string }) => (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z"
/>
</svg>
);
const ChatIcon = ({ className }: { className?: string }) => (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z"
/>
</svg>
);
const UserIcon = ({ className }: { className?: string }) => (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
/>
</svg>
);
const UsersIcon = ({ className }: { className?: string }) => (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
);
const ShieldIcon = ({ className }: { className?: string }) => (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
</svg>
);
const LogoutIcon = ({ className }: { className?: string }) => (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75"
/>
</svg>
);
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet';
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V';
interface AppShellProps {
children: React.ReactNode;
}
export function AppShell({ children }: AppShellProps) {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const { isAdmin, isAuthenticated, logout } = useAuthStore();
const { isFullscreen, safeAreaInset, contentSafeAreaInset, requestFullscreen, isTelegramWebApp } =
useTelegramWebApp();
const haptic = useHaptic();
// Only apply fullscreen UI adjustments on mobile Telegram (iOS/Android)
const isMobileFullscreen = isFullscreen && isTelegramMobile();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isKeyboardOpen, setIsKeyboardOpen] = useState(false);
// Scroll position restoration for admin pages
const scrollPositions = useRef<Record<string, number>>({});
// Disable browser's automatic scroll restoration
useEffect(() => {
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual';
}
}, []);
// Continuously save scroll position for current path
useEffect(() => {
const currentPath = location.pathname;
// Only track scroll for admin pages
if (!currentPath.startsWith('/admin')) return;
const handleScroll = () => {
scrollPositions.current[currentPath] = window.scrollY;
};
// Save on scroll
window.addEventListener('scroll', handleScroll, { passive: true });
// Restore scroll position immediately (synchronous)
const savedPosition = scrollPositions.current[currentPath];
if (savedPosition !== undefined && savedPosition > 0) {
// Immediate restore
window.scrollTo({ top: savedPosition, behavior: 'instant' });
}
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [location.pathname]);
// Branding
const { data: branding } = useQuery({
queryKey: ['branding'],
queryFn: async () => {
const data = await brandingApi.getBranding();
setCachedBranding(data);
preloadLogo(data);
return data;
},
initialData: getCachedBranding() ?? undefined,
staleTime: 60000,
enabled: isAuthenticated,
});
const appName = branding ? branding.name : FALLBACK_NAME;
const logoLetter = branding?.logo_letter || FALLBACK_LOGO;
const hasCustomLogo = branding?.has_custom_logo || false;
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null;
// Set document title
useEffect(() => {
document.title = appName || 'VPN';
}, [appName]);
// Update favicon
useEffect(() => {
if (!logoUrl) return;
const link =
document.querySelector<HTMLLinkElement>("link[rel*='icon']") ||
document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'shortcut icon';
link.href = logoUrl;
document.head.appendChild(link);
}, [logoUrl]);
// Fullscreen setting from server
const { data: fullscreenSetting } = useQuery({
queryKey: ['fullscreen-enabled'],
queryFn: brandingApi.getFullscreenEnabled,
staleTime: 60000,
});
// Apply fullscreen setting when loaded from server
// Only apply on mobile Telegram (iOS/Android) - desktop doesn't need fullscreen
useEffect(() => {
if (!fullscreenSetting || !isTelegramWebApp) return;
// Update cache for future app starts
setCachedFullscreenEnabled(fullscreenSetting.enabled);
// Request fullscreen if enabled, not already fullscreen, and on mobile Telegram
if (fullscreenSetting.enabled && !isFullscreen && isTelegramMobile()) {
requestFullscreen();
}
}, [fullscreenSetting, isTelegramWebApp, isFullscreen, requestFullscreen]);
// Feature flags
const { data: referralTerms } = useQuery({
queryKey: ['referral-terms'],
queryFn: referralApi.getReferralTerms,
enabled: isAuthenticated,
staleTime: 60000,
retry: false,
});
const { data: wheelConfig } = useQuery({
queryKey: ['wheel-config'],
queryFn: wheelApi.getConfig,
enabled: isAuthenticated,
staleTime: 60000,
retry: false,
});
const { data: contestsCount } = useQuery({
queryKey: ['contests-count'],
queryFn: contestsApi.getCount,
enabled: isAuthenticated,
staleTime: 60000,
retry: false,
});
const { data: pollsCount } = useQuery({
queryKey: ['polls-count'],
queryFn: pollsApi.getCount,
enabled: isAuthenticated,
staleTime: 60000,
retry: false,
});
// BackButton for Telegram Mini App
// Don't show back button on main tab pages (bottom nav) - users navigate via tabs
const mainTabPaths = ['/', '/subscription', '/balance', '/referral', '/support'];
const isMainTabPage = mainTabPaths.includes(location.pathname);
const handleBack = useCallback(() => {
if (mobileMenuOpen) {
setMobileMenuOpen(false);
return;
}
navigate(-1);
}, [mobileMenuOpen, navigate]);
useBackButton(isMainTabPage ? null : handleBack);
// 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 items
const desktopNavItems = [
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
{ path: '/balance', label: t('nav.balance'), icon: CreditCardIcon },
{ path: '/support', label: t('nav.support'), icon: ChatIcon },
{ 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');
};
// Calculate header height based on fullscreen mode (only on mobile Telegram)
const headerHeight = isMobileFullscreen
? 64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45
: 64;
return (
<div className="min-h-screen bg-dark-950">
{/* Global components */}
<WebSocketNotifications />
<SuccessNotificationModal />
{/* Desktop Header */}
<header className="fixed left-0 right-0 top-0 z-50 hidden border-b border-dark-800/50 bg-dark-950/80 backdrop-blur-xl lg:block">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between 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 items-center gap-1">
{desktopNavItems.map((item) => (
<Link
key={item.path}
to={item.path}
onClick={handleNavClick}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive(item.path)
? 'bg-dark-800 text-dark-50'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
)}
>
<item.icon className="h-4 w-4" />
<span>{item.label}</span>
</Link>
))}
{referralTerms?.is_enabled && (
<Link
to="/referral"
onClick={handleNavClick}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive('/referral')
? 'bg-dark-800 text-dark-50'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
)}
>
<UsersIcon className="h-4 w-4" />
<span>{t('nav.referral')}</span>
</Link>
)}
{isAdmin && (
<>
{/* Separator before admin */}
<div className="mx-2 h-5 w-px bg-dark-700" />
<Link
to="/admin"
onClick={handleNavClick}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
location.pathname.startsWith('/admin')
? 'bg-warning-500/10 text-warning-400'
: 'text-warning-500/70 hover:bg-warning-500/10 hover:text-warning-400',
)}
>
<ShieldIcon className="h-4 w-4" />
<span>{t('admin.nav.title')}</span>
</Link>
</>
)}
</nav>
{/* Right side actions */}
<div className="flex items-center gap-2">
<TicketNotificationBell isAdmin={location.pathname.startsWith('/admin')} />
<LanguageSwitcher />
<button
onClick={() => {
haptic.impact('light');
logout();
}}
className="flex h-8 w-8 items-center justify-center rounded-lg text-dark-400 transition-colors hover:bg-dark-800/50 hover:text-dark-200"
title={t('nav.logout')}
>
<LogoutIcon className="h-4 w-4" />
</button>
</div>
</div>
</header>
{/* Mobile Header */}
<AppHeader
mobileMenuOpen={mobileMenuOpen}
setMobileMenuOpen={setMobileMenuOpen}
onCommandPaletteOpen={() => {}}
headerHeight={headerHeight}
isFullscreen={isMobileFullscreen}
safeAreaInset={safeAreaInset}
contentSafeAreaInset={contentSafeAreaInset}
wheelEnabled={wheelConfig?.is_enabled}
referralEnabled={referralTerms?.is_enabled}
hasContests={(contestsCount?.count ?? 0) > 0}
hasPolls={(pollsCount?.count ?? 0) > 0}
/>
{/* 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">
{/* Disable animations for admin pages to prevent scroll jumps in Telegram Mini App */}
{location.pathname.startsWith('/admin') ? (
children
) : (
<AnimatePresence mode="popLayout">
<motion.div
key={location.pathname}
variants={slideUp}
initial="initial"
animate="animate"
exit="exit"
transition={slideUpTransition}
>
{children}
</motion.div>
</AnimatePresence>
)}
</main>
{/* Mobile Bottom Navigation */}
<MobileBottomNav
isKeyboardOpen={isKeyboardOpen}
referralEnabled={referralTerms?.is_enabled}
/>
</div>
);
}

View File

@@ -0,0 +1,210 @@
import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/store/auth';
import {
brandingApi,
getCachedBranding,
setCachedBranding,
preloadLogo,
isLogoPreloaded,
} from '@/api/branding';
import { cn } from '@/lib/utils';
import { usePlatform } from '@/platform';
// Icons
import {
HomeIcon,
SubscriptionIcon,
WalletIcon,
UsersIcon,
ChatIcon,
UserIcon,
LogoutIcon,
GamepadIcon,
ClipboardIcon,
InfoIcon,
CogIcon,
WheelIcon,
} from './icons';
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet';
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V';
interface DesktopSidebarProps {
isAdmin?: boolean;
wheelEnabled?: boolean;
referralEnabled?: boolean;
hasContests?: boolean;
hasPolls?: boolean;
}
export function DesktopSidebar({
isAdmin,
wheelEnabled,
referralEnabled,
hasContests,
hasPolls,
}: DesktopSidebarProps) {
const { t } = useTranslation();
const location = useLocation();
const { user, logout } = useAuthStore();
const { haptic } = usePlatform();
// Branding
const { data: branding } = useQuery({
queryKey: ['branding'],
queryFn: async () => {
const data = await brandingApi.getBranding();
setCachedBranding(data);
preloadLogo(data);
return data;
},
initialData: getCachedBranding() ?? undefined,
staleTime: 60000,
refetchOnWindowFocus: true,
retry: 1,
});
const appName = branding ? branding.name : FALLBACK_NAME;
const logoLetter = branding?.logo_letter || FALLBACK_LOGO;
const hasCustomLogo = branding?.has_custom_logo || false;
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null;
const isActive = (path: string) => location.pathname === path;
const isAdminActive = () => location.pathname.startsWith('/admin');
const navItems = [
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
{ path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon },
{ path: '/balance', label: t('nav.balance'), icon: WalletIcon },
...(referralEnabled ? [{ path: '/referral', label: t('nav.referral'), icon: UsersIcon }] : []),
{ path: '/support', label: t('nav.support'), icon: ChatIcon },
...(hasContests ? [{ path: '/contests', label: t('nav.contests'), icon: GamepadIcon }] : []),
...(hasPolls ? [{ path: '/polls', label: t('nav.polls'), icon: ClipboardIcon }] : []),
...(wheelEnabled ? [{ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon }] : []),
{ path: '/info', label: t('nav.info'), icon: InfoIcon },
];
const handleNavClick = () => {
haptic.impact('light');
};
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">
{/* 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}>
<div className="relative flex h-10 w-10 flex-shrink-0 items-center justify-center overflow-hidden rounded-linear-lg border border-dark-700/50 bg-dark-800/80">
<span
className={cn(
'absolute text-lg 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>
{appName && (
<span className="whitespace-nowrap text-base font-semibold text-dark-100">
{appName}
</span>
)}
</Link>
</div>
{/* Navigation */}
<nav className="flex-1 space-y-1 overflow-y-auto p-3">
{navItems.map((item) => (
<Link
key={item.path}
to={item.path}
onClick={handleNavClick}
className={cn(
'group flex items-center gap-3 rounded-linear px-3 py-2.5 text-sm font-medium transition-all duration-200',
isActive(item.path)
? 'bg-accent-500/10 text-accent-400'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-100',
)}
>
<item.icon className="h-5 w-5 shrink-0" />
<span>{item.label}</span>
{isActive(item.path) && (
<motion.div
layoutId="sidebar-active-indicator"
className="absolute left-0 h-8 w-0.5 rounded-r-full bg-accent-400"
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
/>
)}
</Link>
))}
{/* Admin section */}
{isAdmin && (
<>
<div className="my-3 h-px bg-dark-700/30" />
<Link
to="/admin"
onClick={handleNavClick}
className={cn(
'group flex items-center gap-3 rounded-linear px-3 py-2.5 text-sm font-medium transition-all duration-200',
isAdminActive()
? 'bg-warning-500/10 text-warning-400'
: 'text-warning-500/70 hover:bg-warning-500/10 hover:text-warning-400',
)}
>
<CogIcon className="h-5 w-5 shrink-0" />
<span>{t('admin.nav.title')}</span>
</Link>
</>
)}
</nav>
{/* User section */}
<div className="border-t border-dark-700/30 p-3">
<Link
to="/profile"
onClick={handleNavClick}
className={cn(
'group flex items-center gap-3 rounded-linear px-3 py-2.5 transition-all duration-200',
isActive('/profile') ? 'bg-dark-800/80' : 'hover:bg-dark-800/50',
)}
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-dark-700">
<UserIcon className="h-4 w-4 text-dark-400" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-dark-100">
{user?.first_name || user?.username || `#${user?.telegram_id}`}
</p>
<p className="truncate text-xs text-dark-500">
@{user?.username || `ID: ${user?.telegram_id}`}
</p>
</div>
</Link>
<button
onClick={() => {
haptic.impact('light');
logout();
}}
className="mt-2 flex w-full items-center gap-3 rounded-linear px-3 py-2.5 text-sm text-dark-400 transition-all duration-200 hover:bg-error-500/10 hover:text-error-400"
>
<LogoutIcon className="h-5 w-5 shrink-0" />
<span>{t('nav.logout')}</span>
</button>
</div>
</aside>
);
}

View File

@@ -0,0 +1,78 @@
import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import { usePlatform } from '@/platform';
// Icons
import { HomeIcon, SubscriptionIcon, WalletIcon, UsersIcon, ChatIcon } from './icons';
interface MobileBottomNavProps {
isKeyboardOpen: boolean;
referralEnabled?: boolean;
}
export function MobileBottomNav({ isKeyboardOpen, referralEnabled }: MobileBottomNavProps) {
const { t } = useTranslation();
const location = useLocation();
const { haptic } = usePlatform();
const isActive = (path: string) => location.pathname === path;
// Core navigation items for bottom bar
const coreItems = [
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
{ path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon },
{ path: '/balance', label: t('nav.balance'), icon: WalletIcon },
...(referralEnabled ? [{ path: '/referral', label: t('nav.referral'), icon: UsersIcon }] : []),
{ path: '/support', label: t('nav.support'), icon: ChatIcon },
];
const handleNavClick = () => {
haptic.impact('light');
};
return (
<nav
className={cn(
'fixed z-50 transition-all duration-200 lg:hidden',
'bg-dark-900/95 backdrop-blur-linear',
'border border-dark-700/30',
isKeyboardOpen ? 'pointer-events-none opacity-0' : 'opacity-100',
)}
style={{
bottom: 'calc(16px + env(safe-area-inset-bottom, 0px))',
left: '16px',
right: '16px',
borderRadius: 'var(--bento-radius, 24px)',
padding: '8px 4px',
boxShadow: '0 4px 30px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05) inset',
}}
>
<div className="flex justify-around">
{coreItems.map((item) => (
<Link
key={item.path}
to={item.path}
onClick={handleNavClick}
className={cn(
'relative flex min-w-[56px] flex-1 shrink-0 flex-col items-center justify-center rounded-2xl px-3 py-2.5 transition-all duration-200',
isActive(item.path) ? 'text-accent-400' : 'text-dark-500 hover:text-dark-300',
)}
>
{isActive(item.path) && (
<motion.div
layoutId="bottom-nav-active"
className="absolute inset-0 rounded-2xl bg-accent-500/15"
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
/>
)}
<item.icon className="relative z-10 h-5 w-5" />
<span className="relative z-10 mt-1 whitespace-nowrap text-2xs">{item.label}</span>
</Link>
))}
</div>
</nav>
);
}

View File

@@ -0,0 +1,330 @@
import { cn } from '@/lib/utils';
interface IconProps {
className?: string;
}
export const HomeIcon = ({ className }: IconProps) => (
<svg
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
/>
</svg>
);
export const SubscriptionIcon = ({ className }: IconProps) => (
<svg
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
/>
</svg>
);
export const WalletIcon = ({ className }: IconProps) => (
<svg
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3"
/>
</svg>
);
export const UsersIcon = ({ className }: IconProps) => (
<svg
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
);
export const ChatIcon = ({ className }: IconProps) => (
<svg
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z"
/>
</svg>
);
export const UserIcon = ({ className }: IconProps) => (
<svg
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
/>
</svg>
);
export const LogoutIcon = ({ className }: IconProps) => (
<svg
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9"
/>
</svg>
);
export const SunIcon = ({ className }: IconProps) => (
<svg
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
/>
</svg>
);
export const MoonIcon = ({ className }: IconProps) => (
<svg
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
/>
</svg>
);
export const MenuIcon = ({ className }: IconProps) => (
<svg
className={cn('h-6 w-6', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
);
export const CloseIcon = ({ className }: IconProps) => (
<svg
className={cn('h-6 w-6', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
export const GamepadIcon = ({ className }: IconProps) => (
<svg
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 01-.657.643 48.39 48.39 0 01-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 01-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 00-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.039 48.039 0 01-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 00.657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 005.427-.63 48.05 48.05 0 00.582-4.717.532.532 0 00-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.959.401v0a.656.656 0 00.659-.663 47.703 47.703 0 00-.31-4.82.78.78 0 01.79-.869"
/>
</svg>
);
export const ClipboardIcon = ({ className }: IconProps) => (
<svg
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z"
/>
</svg>
);
export const InfoIcon = ({ className }: IconProps) => (
<svg
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
/>
</svg>
);
export const CogIcon = ({ className }: IconProps) => (
<svg
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
export const WheelIcon = ({ className }: IconProps) => (
<svg
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
/>
</svg>
);
export const SearchIcon = ({ className }: IconProps) => (
<svg
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
);
export const PlusIcon = ({ className }: IconProps) => (
<svg
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
);
export const ArrowRightIcon = ({ className }: IconProps) => (
<svg
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
);
export const DownloadIcon = ({ className }: IconProps) => (
<svg
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
);
export const PaletteIcon = ({ className }: IconProps) => (
<svg
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008z"
/>
</svg>
);

View File

@@ -0,0 +1,4 @@
export { AppShell } from './AppShell';
export { DesktopSidebar } from './DesktopSidebar';
export { MobileBottomNav } from './MobileBottomNav';
export { AppHeader } from './AppHeader';

View File

@@ -1,820 +1,17 @@
import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useState, useEffect, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '../../store/auth';
import LanguageSwitcher from '../LanguageSwitcher';
import PromoDiscountBadge from '../PromoDiscountBadge';
import TicketNotificationBell from '../TicketNotificationBell';
import WebSocketNotifications from '../WebSocketNotifications';
import SuccessNotificationModal from '../SuccessNotificationModal';
import AnimatedBackground from '../AnimatedBackground';
import { contestsApi } from '../../api/contests';
import { pollsApi } from '../../api/polls';
import {
brandingApi,
getCachedBranding,
setCachedBranding,
preloadLogo,
isLogoPreloaded,
} from '../../api/branding';
import { wheelApi } from '../../api/wheel';
import { themeColorsApi } from '../../api/themeColors';
import { promoApi } from '../../api/promo';
import { referralApi } from '../../api/referral';
import { useTheme } from '../../hooks/useTheme';
import { useTelegramWebApp } from '../../hooks/useTelegramWebApp';
import { usePullToRefresh } from '../../hooks/usePullToRefresh';
// Fallback branding from environment variables
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet';
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V';
import { AppShell } from './AppShell';
interface LayoutProps {
children: React.ReactNode;
}
// Icons as simple SVG components
const HomeIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
/>
</svg>
);
const SubscriptionIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
/>
</svg>
);
const WalletIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3"
/>
</svg>
);
const UsersIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
);
const ChatIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z"
/>
</svg>
);
const UserIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
/>
</svg>
);
const LogoutIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9"
/>
</svg>
);
// Theme toggle icons
const SunIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
/>
</svg>
);
const MoonIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
/>
</svg>
);
const MenuIcon = () => (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
);
const CloseIcon = () => (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
const GamepadIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 01-.657.643 48.39 48.39 0 01-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 01-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 00-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.039 48.039 0 01-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 00.657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 005.427-.63 48.05 48.05 0 00.582-4.717.532.532 0 00-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.959.401v0a.656.656 0 00.659-.663 47.703 47.703 0 00-.31-4.82.78.78 0 01.79-.869"
/>
</svg>
);
const ClipboardIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z"
/>
</svg>
);
const InfoIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
/>
</svg>
);
const CogIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
const WheelIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
/>
</svg>
);
/**
* Main layout component that wraps all pages.
* Uses the new AppShell system with:
* - Desktop sidebar navigation
* - Mobile bottom navigation
* - Command palette (⌘K)
* - Platform-aware features (Telegram integration)
*/
export default function Layout({ children }: LayoutProps) {
const { t } = useTranslation();
const location = useLocation();
const { user, logout, isAdmin, isAuthenticated } = useAuthStore();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isKeyboardOpen, setIsKeyboardOpen] = useState(false);
const { toggleTheme, isDark } = useTheme();
const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null);
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp();
// Pull to refresh (disabled when mobile menu is open)
const { isPulling, pullDistance, isRefreshing, progress } = usePullToRefresh({
disabled: mobileMenuOpen,
threshold: 80,
});
// Fetch enabled themes from API - same source of truth as AdminSettings
const { data: enabledThemes } = useQuery({
queryKey: ['enabled-themes'],
queryFn: themeColorsApi.getEnabledThemes,
staleTime: 1000 * 60 * 5, // 5 minutes
});
// Only show theme toggle if both themes are enabled
const canToggle = enabledThemes?.dark && enabledThemes?.light;
// Get user photo from Telegram WebApp
useEffect(() => {
try {
const tg = (
window as unknown as {
Telegram?: { WebApp?: { initDataUnsafe?: { user?: { photo_url?: string } } } };
}
).Telegram?.WebApp;
const photoUrl = tg?.initDataUnsafe?.user?.photo_url;
if (photoUrl) {
setUserPhotoUrl(photoUrl);
}
} catch (e) {
console.warn('Failed to get Telegram user photo:', e);
}
}, []);
// Lock body scroll when mobile menu is open (cross-platform)
// Note: We avoid using body position:fixed with top:-scrollY as it causes issues
// in Telegram Mini App where the menu disappears when opened from scrolled position
useEffect(() => {
if (!mobileMenuOpen) return;
const body = document.body;
const html = document.documentElement;
// Save original styles
const originalStyles = {
bodyOverflow: body.style.overflow,
htmlOverflow: html.style.overflow,
};
// Lock scroll - simple approach without body position manipulation
body.style.overflow = 'hidden';
html.style.overflow = 'hidden';
// Prevent touchmove on body (critical for mobile, especially Telegram Mini App)
const preventScroll = (e: TouchEvent) => {
const target = e.target as HTMLElement;
// Allow scroll inside menu content
if (target.closest('.mobile-menu-content')) return;
e.preventDefault();
};
document.addEventListener('touchmove', preventScroll, { passive: false });
// Also prevent wheel scroll on desktop
const preventWheel = (e: WheelEvent) => {
const target = e.target as HTMLElement;
if (target.closest('.mobile-menu-content')) return;
e.preventDefault();
};
document.addEventListener('wheel', preventWheel, { passive: false });
return () => {
// Restore original styles
body.style.overflow = originalStyles.bodyOverflow;
html.style.overflow = originalStyles.htmlOverflow;
// Remove listeners
document.removeEventListener('touchmove', preventScroll);
document.removeEventListener('wheel', preventWheel);
};
}, [mobileMenuOpen]);
// Detect virtual keyboard by tracking focus on input elements
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;
// Only close if not focusing another input
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);
};
}, []);
// State to track if logo image has loaded - start with true if already preloaded
const [logoLoaded, setLogoLoaded] = useState(() => isLogoPreloaded());
// Fetch branding settings with localStorage cache for instant load
const { data: branding } = useQuery({
queryKey: ['branding'],
queryFn: async () => {
const data = await brandingApi.getBranding();
setCachedBranding(data); // Update cache
// Preload logo in background
preloadLogo(data);
return data;
},
initialData: getCachedBranding() ?? undefined, // Use cached data immediately
staleTime: 60000, // 1 minute
refetchOnWindowFocus: true,
retry: 1,
});
// Computed branding values - use fallback only if no branding and no cache
const appName = branding ? branding.name : FALLBACK_NAME; // Empty string is valid (logo-only mode)
const logoLetter = branding?.logo_letter || FALLBACK_LOGO;
const hasCustomLogo = branding?.has_custom_logo || false;
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null;
// Set document title
useEffect(() => {
document.title = appName || 'VPN'; // Fallback title if name is empty
}, [appName]);
// Update favicon
useEffect(() => {
if (!logoUrl) return;
const link =
document.querySelector<HTMLLinkElement>("link[rel*='icon']") ||
document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'shortcut icon';
link.href = logoUrl;
document.getElementsByTagName('head')[0].appendChild(link);
}, [logoUrl]);
// Fetch contests and polls counts to determine if they should be shown
const { data: contestsCount } = useQuery({
queryKey: ['contests-count'],
queryFn: contestsApi.getCount,
enabled: isAuthenticated,
staleTime: 60000, // 1 minute
retry: false,
});
const { data: pollsCount } = useQuery({
queryKey: ['polls-count'],
queryFn: pollsApi.getCount,
enabled: isAuthenticated,
staleTime: 60000, // 1 minute
retry: false,
});
// Fetch wheel config to check if enabled
const { data: wheelConfig } = useQuery({
queryKey: ['wheel-config'],
queryFn: wheelApi.getConfig,
enabled: isAuthenticated,
staleTime: 60000, // 1 minute
retry: false,
});
// Fetch referral terms to check if enabled
const { data: referralTerms } = useQuery({
queryKey: ['referral-terms'],
queryFn: referralApi.getReferralTerms,
enabled: isAuthenticated,
staleTime: 60000, // 1 minute
retry: false,
});
// Fetch active discount to determine mobile layout
const { data: activeDiscount } = useQuery({
queryKey: ['active-discount'],
queryFn: promoApi.getActiveDiscount,
enabled: isAuthenticated,
staleTime: 30000,
});
// Check if promo is active (to hide language switcher on mobile)
const isPromoActive = activeDiscount?.is_active && activeDiscount?.discount_percent;
const navItems = useMemo(() => {
const items = [
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
{ path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon },
{ path: '/balance', label: t('nav.balance'), icon: WalletIcon },
];
// Only show referral if program is enabled
if (referralTerms?.is_enabled) {
items.push({ path: '/referral', label: t('nav.referral'), icon: UsersIcon });
}
items.push({ path: '/support', label: t('nav.support'), icon: ChatIcon });
// Only show contests if there are available contests
if (contestsCount && contestsCount.count > 0) {
items.push({ path: '/contests', label: t('nav.contests'), icon: GamepadIcon });
}
// Only show polls if there are available polls
if (pollsCount && pollsCount.count > 0) {
items.push({ path: '/polls', label: t('nav.polls'), icon: ClipboardIcon });
}
items.push({ path: '/info', label: t('nav.info'), icon: InfoIcon });
return items;
}, [t, contestsCount, pollsCount, referralTerms]);
// Separate navItems for desktop that includes wheel (if enabled)
const desktopNavItems = useMemo(() => {
const items = [...navItems];
// Add wheel before info if enabled
if (wheelConfig?.is_enabled) {
const infoIndex = items.findIndex((item) => item.path === '/info');
if (infoIndex !== -1) {
items.splice(infoIndex, 0, { path: '/wheel', label: t('nav.wheel'), icon: WheelIcon });
} else {
items.push({ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon });
}
}
return items;
}, [navItems, wheelConfig, t]);
const adminNavItems = [{ path: '/admin', label: t('admin.nav.title'), icon: CogIcon }];
const isActive = (path: string) => location.pathname === path;
const isAdminActive = () => location.pathname.startsWith('/admin');
return (
<div className="flex min-h-screen flex-col">
{/* Global WebSocket notifications handler */}
<WebSocketNotifications />
{/* Global success notification modal */}
<SuccessNotificationModal />
{/* Animated Background */}
<AnimatedBackground />
{/* Pull to refresh indicator */}
{(isPulling || isRefreshing) && (
<div
className="fixed left-1/2 z-[100] flex -translate-x-1/2 items-center justify-center transition-all duration-200"
style={{
top: `calc(${Math.max(pullDistance, isRefreshing ? 40 : 0)}px + env(safe-area-inset-top, 0px) + 0.5rem)`,
opacity: isRefreshing ? 1 : progress,
}}
>
<div
className={`flex h-10 w-10 items-center justify-center rounded-full border border-dark-700 bg-dark-800 shadow-lg ${isRefreshing ? 'animate-pulse' : ''}`}
>
<svg
className={`h-5 w-5 text-accent-400 transition-transform duration-200 ${isRefreshing ? 'animate-spin' : ''}`}
style={{ transform: isRefreshing ? undefined : `rotate(${progress * 360}deg)` }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</div>
</div>
)}
{/* Header */}
<header
className="glass fixed left-0 right-0 top-0 z-50 shadow-lg shadow-black/10"
style={{
// In fullscreen mode, add padding for safe area + Telegram native controls (close/menu buttons in corners)
paddingTop: isFullscreen
? `${Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px`
: undefined,
}}
>
<div
className="mx-auto w-full px-4 sm:px-6"
onClick={() => mobileMenuOpen && setMobileMenuOpen(false)}
>
<div className="flex h-16 items-center justify-between lg:h-20">
{/* Logo */}
<Link
to="/"
onClick={() => setMobileMenuOpen(false)}
className={`flex flex-shrink-0 items-center gap-2.5 ${!appName ? 'lg:mr-4' : ''}`}
>
<div className="relative flex h-10 w-10 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl border border-dark-700/50 bg-dark-800/80 shadow-md dark:bg-dark-800/80 sm:h-11 sm:w-11 lg:h-12 lg:w-12">
{/* Always show letter as fallback */}
<span
className={`absolute text-lg font-bold text-accent-400 transition-opacity duration-200 sm:text-xl ${hasCustomLogo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}
>
{logoLetter}
</span>
{/* Logo image with smooth fade-in */}
{hasCustomLogo && logoUrl && (
<img
src={logoUrl}
alt={appName || 'Logo'}
className={`absolute h-full w-full object-contain transition-opacity duration-200 ${logoLoaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setLogoLoaded(true)}
/>
)}
</div>
{appName && (
<span className="whitespace-nowrap text-base font-semibold text-dark-100 lg:text-lg">
{appName}
</span>
)}
</Link>
{/* Desktop Navigation */}
<nav className="hidden items-center gap-1 lg:flex">
{desktopNavItems.map((item) => (
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all duration-200 ${
isActive(item.path)
? 'bg-accent-500/10 text-accent-400'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-100'
}`}
>
<item.icon />
{item.label}
</Link>
))}
{isAdmin && (
<>
<div className="mx-2 h-6 w-px bg-dark-700" />
{adminNavItems.map((item) => (
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all duration-200 ${
isAdminActive()
? 'bg-warning-500/10 text-warning-400'
: 'text-warning-500/70 hover:bg-warning-500/10 hover:text-warning-400'
}`}
>
<item.icon />
{item.label}
</Link>
))}
</>
)}
</nav>
{/* Right side */}
<div className="flex items-center gap-1.5 sm:gap-2">
{/* Theme toggle button - only show if both themes are enabled */}
{canToggle && (
<button
onClick={() => {
toggleTheme();
setMobileMenuOpen(false);
}}
className="relative rounded-xl border border-dark-700/50 bg-dark-800/50 p-2 text-champagne-500 transition-all duration-200 hover:bg-dark-700 hover:text-champagne-800 dark:text-dark-400 dark:hover:text-accent-400"
title={isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'}
aria-label={
isDark
? t('theme.light') || 'Switch to light mode'
: t('theme.dark') || 'Switch to dark mode'
}
>
<div className="relative h-5 w-5">
<div
className={`absolute inset-0 transition-all duration-300 ${isDark ? 'rotate-0 opacity-100' : 'rotate-90 opacity-0'}`}
>
<MoonIcon />
</div>
<div
className={`absolute inset-0 transition-all duration-300 ${isDark ? '-rotate-90 opacity-0' : 'rotate-0 opacity-100'}`}
>
<SunIcon />
</div>
</div>
</button>
)}
<div onClick={() => setMobileMenuOpen(false)}>
<PromoDiscountBadge />
</div>
<div onClick={() => setMobileMenuOpen(false)}>
<TicketNotificationBell isAdmin={isAdminActive()} />
</div>
{/* Hide language switcher on mobile when promo is active */}
<div
className={isPromoActive ? 'hidden sm:block' : ''}
onClick={() => setMobileMenuOpen(false)}
>
<LanguageSwitcher />
</div>
{/* Profile - Desktop */}
<div className="hidden items-center gap-3 sm:flex">
<Link
to="/profile"
className="flex items-center gap-2 rounded-lg px-3 py-1.5 transition-colors hover:bg-dark-800/50"
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-dark-700">
<UserIcon />
</div>
<span className="text-sm text-dark-300">
{user?.first_name || user?.username || `#${user?.telegram_id}`}
</span>
</Link>
<button
onClick={logout}
className="btn-icon"
title={t('nav.logout')}
aria-label={t('nav.logout') || 'Logout'}
>
<LogoutIcon />
</button>
</div>
{/* Mobile menu button */}
<button
onClick={(e) => {
e.stopPropagation();
setMobileMenuOpen(!mobileMenuOpen);
}}
className="btn-icon lg:hidden"
aria-label={
mobileMenuOpen ? t('common.close') || 'Close menu' : t('nav.menu') || 'Open menu'
}
aria-expanded={mobileMenuOpen}
>
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
</button>
</div>
</div>
</div>
</header>
{/* Spacer for fixed header - matches header height */}
{isFullscreen ? (
<div
className="flex-shrink-0"
style={{ height: `${64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px` }}
/>
) : (
<div className="h-16 flex-shrink-0 lg:h-20" />
)}
{/* Mobile menu - fixed overlay below header */}
{mobileMenuOpen && (
<div
className="fixed inset-x-0 bottom-0 z-40 animate-fade-in lg:hidden"
style={{
top: isFullscreen
? `${64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px`
: '64px',
}}
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={() => setMobileMenuOpen(false)}
/>
{/* Menu content */}
<div
className="mobile-menu-content absolute inset-x-0 bottom-0 top-0 overflow-y-auto overscroll-contain border-t border-dark-800/50 bg-dark-900 pb-[calc(5rem+env(safe-area-inset-bottom,0px))]"
style={{ WebkitOverflowScrolling: 'touch' }}
>
<div className="mx-auto max-w-6xl px-4 py-4">
{/* User info */}
<div className="mb-4 flex items-center justify-between border-b border-dark-800/50 pb-4">
<div className="flex items-center gap-3">
{userPhotoUrl ? (
<img
src={userPhotoUrl}
alt="Avatar"
className="h-10 w-10 rounded-full object-cover"
onError={(e) => {
e.currentTarget.style.display = 'none';
e.currentTarget.nextElementSibling?.classList.remove('hidden');
}}
/>
) : null}
<div
className={`flex h-10 w-10 items-center justify-center rounded-full bg-dark-700 ${userPhotoUrl ? 'hidden' : ''}`}
>
<UserIcon />
</div>
<div>
<div className="text-sm font-medium text-dark-100">
{user?.first_name || user?.username}
</div>
<div className="text-xs text-dark-500">
@{user?.username || `ID: ${user?.telegram_id}`}
</div>
</div>
</div>
{/* Language switcher in mobile menu when promo is active */}
{isPromoActive && <LanguageSwitcher />}
</div>
{/* Nav items */}
<nav className="space-y-1">
{desktopNavItems.map((item) => (
<Link
key={item.path}
to={item.path}
onClick={() => setMobileMenuOpen(false)}
className={isActive(item.path) ? 'nav-item-active' : 'nav-item'}
>
<item.icon />
{item.label}
</Link>
))}
{isAdmin && (
<>
<div className="divider my-3" />
<div className="px-4 py-1 text-xs font-medium uppercase tracking-wider text-dark-500">
{t('admin.nav.title')}
</div>
{adminNavItems.map((item) => (
<Link
key={item.path}
to={item.path}
onClick={() => setMobileMenuOpen(false)}
className={`nav-item ${isAdminActive() ? 'bg-warning-500/10 text-warning-400' : 'text-warning-500/70'}`}
>
<item.icon />
{item.label}
</Link>
))}
</>
)}
<div className="divider my-3" />
<Link
to="/profile"
onClick={() => setMobileMenuOpen(false)}
className={isActive('/profile') ? 'nav-item-active' : 'nav-item'}
>
<UserIcon />
{t('nav.profile')}
</Link>
<button
onClick={() => {
setMobileMenuOpen(false);
logout();
}}
className="nav-item w-full text-error-400"
>
<LogoutIcon />
{t('nav.logout')}
</button>
</nav>
</div>
</div>
</div>
)}
{/* Main Content */}
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-6 pb-24 sm:px-6 lg:pb-8">
<div className="animate-fade-in">{children}</div>
</main>
{/* Mobile Bottom Navigation - only core items, hidden when keyboard is open */}
<nav
className={`bottom-nav transition-opacity duration-200 lg:hidden ${isKeyboardOpen ? 'pointer-events-none opacity-0' : 'opacity-100'}`}
>
<div className="flex justify-around">
{navItems
.filter((item) =>
['/', '/subscription', '/balance', '/referral', '/support'].includes(item.path),
)
.map((item) => (
<Link
key={item.path}
to={item.path}
onClick={() => setMobileMenuOpen(false)}
className={isActive(item.path) ? 'bottom-nav-item-active' : 'bottom-nav-item'}
>
<item.icon />
<span className="mt-1 whitespace-nowrap text-2xs">{item.label}</span>
</Link>
))}
</div>
</nav>
</div>
);
return <AppShell>{children}</AppShell>;
}

View File

@@ -0,0 +1,33 @@
import { motion, type HTMLMotionProps } from 'framer-motion';
import { forwardRef, type ReactNode } from 'react';
import { fadeIn, fadeInTransition } from './transitions';
interface FadeInProps extends Omit<HTMLMotionProps<'div'>, 'initial' | 'animate' | 'exit'> {
children: ReactNode;
delay?: number;
duration?: number;
}
export const FadeIn = forwardRef<HTMLDivElement, FadeInProps>(
({ children, delay = 0, duration, ...props }, ref) => {
return (
<motion.div
ref={ref}
variants={fadeIn}
initial="initial"
animate="animate"
exit="exit"
transition={{
...fadeInTransition,
delay,
...(duration !== undefined && { duration }),
}}
{...props}
>
{children}
</motion.div>
);
},
);
FadeIn.displayName = 'FadeIn';

View File

@@ -0,0 +1,33 @@
import { motion, type HTMLMotionProps } from 'framer-motion';
import { forwardRef, type ReactNode } from 'react';
import { slideUp, slideUpTransition } from './transitions';
interface SlideUpProps extends Omit<HTMLMotionProps<'div'>, 'initial' | 'animate' | 'exit'> {
children: ReactNode;
delay?: number;
duration?: number;
}
export const SlideUp = forwardRef<HTMLDivElement, SlideUpProps>(
({ children, delay = 0, duration, ...props }, ref) => {
return (
<motion.div
ref={ref}
variants={slideUp}
initial="initial"
animate="animate"
exit="exit"
transition={{
...slideUpTransition,
delay,
...(duration !== undefined && { duration }),
}}
{...props}
>
{children}
</motion.div>
);
},
);
SlideUp.displayName = 'SlideUp';

View File

@@ -0,0 +1,4 @@
export { AnimatePresence } from 'framer-motion';
export { FadeIn } from './FadeIn';
export { SlideUp } from './SlideUp';
export * from './transitions';

View File

@@ -0,0 +1,180 @@
import type { Transition, Variants } from 'framer-motion';
// Spring transition for micro-interactions
export const springTransition: Transition = {
type: 'spring',
stiffness: 500,
damping: 30,
};
// Smooth spring for larger movements
export const smoothSpring: Transition = {
type: 'spring',
stiffness: 300,
damping: 25,
};
// Expo easing curve (Linear-style)
export const easeOutExpo = [0.16, 1, 0.3, 1] as const;
// Fade in animation
export const fadeIn: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
};
export const fadeInTransition: Transition = {
duration: 0.2,
ease: easeOutExpo,
};
// Slide up animation (for page content, cards)
// Exit is instant to avoid visual glitches in Telegram Mini App
export const slideUp: Variants = {
initial: { opacity: 0, y: 8 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, transition: { duration: 0 } },
};
export const slideUpTransition: Transition = {
duration: 0.2,
ease: easeOutExpo,
};
// Slide down animation (for dropdowns, popovers)
export const slideDown: Variants = {
initial: { opacity: 0, y: -8 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0 },
};
// Slide from right (for sheets, sidebars)
export const slideRight: Variants = {
initial: { opacity: 0, x: 20 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: 20 },
};
// Slide from left
export const slideLeft: Variants = {
initial: { opacity: 0, x: -20 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -20 },
};
// Scale animation (for modals, dialogs)
export const scale: Variants = {
initial: { opacity: 0, scale: 0.95 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.95 },
};
export const scaleTransition: Transition = {
duration: 0.2,
ease: easeOutExpo,
};
// Stagger container for lists
export const staggerContainer: Variants = {
initial: {},
animate: {
transition: {
staggerChildren: 0.05,
delayChildren: 0.02,
},
},
exit: {
transition: {
staggerChildren: 0.02,
staggerDirection: -1,
},
},
};
// Fast stagger for dense lists
export const fastStaggerContainer: Variants = {
initial: {},
animate: {
transition: {
staggerChildren: 0.03,
},
},
};
// Stagger item (use with staggerContainer)
// Exit is instant to avoid visual glitches in Telegram Mini App
export const staggerItem: Variants = {
initial: { opacity: 0, y: 8 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, transition: { duration: 0 } },
};
// Backdrop overlay
export const backdrop: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
};
export const backdropTransition: Transition = {
duration: 0.15,
};
// Button press animation values
export const buttonTap = {
scale: 0.98,
};
export const buttonHover = {
scale: 1.02,
};
// Sheet/drawer slide up from bottom
export const sheetSlideUp: Variants = {
initial: { y: '100%' },
animate: { y: 0 },
exit: { y: '100%' },
};
export const sheetTransition: Transition = {
type: 'spring',
damping: 30,
stiffness: 400,
};
// Tooltip animation
export const tooltip: Variants = {
initial: { opacity: 0, scale: 0.96, y: 2 },
animate: { opacity: 1, scale: 1, y: 0 },
exit: { opacity: 0, scale: 0.96, y: 2 },
};
export const tooltipTransition: Transition = {
duration: 0.15,
ease: easeOutExpo,
};
// Dropdown menu animation
export const dropdown: Variants = {
initial: { opacity: 0, scale: 0.96, y: -4 },
animate: { opacity: 1, scale: 1, y: 0 },
exit: { opacity: 0, scale: 0.96, y: -4 },
};
export const dropdownTransition: Transition = {
duration: 0.15,
ease: easeOutExpo,
};
// Command palette animation
export const commandPalette: Variants = {
initial: { opacity: 0, scale: 0.98 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.98 },
};
export const commandPaletteTransition: Transition = {
duration: 0.15,
ease: easeOutExpo,
};

View File

@@ -0,0 +1,218 @@
import { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { AnimatePresence, motion } from 'framer-motion';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
} from '@/components/primitives/Command';
import { usePlatform } from '@/platform';
import { useAuthStore } from '@/store/auth';
import { useTheme } from '@/hooks/useTheme';
import { cn } from '@/lib/utils';
import {
backdrop,
backdropTransition,
commandPalette,
commandPaletteTransition,
} from '@/components/motion/transitions';
// Icons
import {
HomeIcon,
SubscriptionIcon,
WalletIcon,
UsersIcon,
ChatIcon,
UserIcon,
GamepadIcon,
ClipboardIcon,
InfoIcon,
CogIcon,
WheelIcon,
PlusIcon,
DownloadIcon,
SunIcon,
MoonIcon,
} from '@/components/layout/AppShell/icons';
interface CommandPaletteProps {
open: boolean;
onOpenChange: (open: boolean) => void;
wheelEnabled?: boolean;
referralEnabled?: boolean;
hasContests?: boolean;
hasPolls?: boolean;
}
export function CommandPalette({
open,
onOpenChange,
wheelEnabled,
referralEnabled,
hasContests,
hasPolls,
}: CommandPaletteProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { haptic } = usePlatform();
const { isAdmin } = useAuthStore();
const { toggleTheme, isDark } = useTheme();
const [search, setSearch] = useState('');
const runCommand = useCallback(
(command: () => void) => {
haptic.impact('light');
onOpenChange(false);
command();
},
[haptic, onOpenChange],
);
// Navigation items
const navigationItems = [
{ label: t('nav.dashboard'), icon: HomeIcon, path: '/' },
{ label: t('nav.subscription'), icon: SubscriptionIcon, path: '/subscription' },
{ label: t('nav.balance'), icon: WalletIcon, path: '/balance' },
...(referralEnabled ? [{ label: t('nav.referral'), icon: UsersIcon, path: '/referral' }] : []),
{ label: t('nav.support'), icon: ChatIcon, path: '/support' },
...(hasContests ? [{ label: t('nav.contests'), icon: GamepadIcon, path: '/contests' }] : []),
...(hasPolls ? [{ label: t('nav.polls'), icon: ClipboardIcon, path: '/polls' }] : []),
...(wheelEnabled ? [{ label: t('nav.wheel'), icon: WheelIcon, path: '/wheel' }] : []),
{ label: t('nav.info'), icon: InfoIcon, path: '/info' },
{ label: t('nav.profile'), icon: UserIcon, path: '/profile' },
...(isAdmin ? [{ label: t('admin.nav.title'), icon: CogIcon, path: '/admin' }] : []),
];
// Action items
const actionItems = [
{
label: t('balance.top_up') || 'Top up balance',
icon: PlusIcon,
action: () => navigate('/balance'),
},
{
label: t('subscription.get_config') || 'Get VPN config',
icon: DownloadIcon,
action: () => navigate('/subscription'),
},
{
label: isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode',
icon: isDark ? SunIcon : MoonIcon,
action: toggleTheme,
},
{
label: t('support.create_ticket') || 'Create support ticket',
icon: ChatIcon,
action: () => navigate('/support'),
},
];
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal forceMount>
<AnimatePresence mode="wait">
{open && (
<>
{/* Backdrop */}
<DialogPrimitive.Overlay asChild>
<motion.div
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm"
variants={backdrop}
initial="initial"
animate="animate"
exit="exit"
transition={backdropTransition}
/>
</DialogPrimitive.Overlay>
{/* Content */}
<DialogPrimitive.Content asChild>
<motion.div
className={cn(
'fixed left-1/2 top-[15%] z-50 w-full max-w-lg -translate-x-1/2',
'overflow-hidden rounded-linear-lg border border-dark-700/50 bg-dark-900/95 backdrop-blur-linear',
'shadow-2xl',
'focus:outline-none',
)}
variants={commandPalette}
initial="initial"
animate="animate"
exit="exit"
transition={commandPaletteTransition}
>
<Command>
<CommandInput
placeholder={t('common.search') || 'Search...'}
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandEmpty>{t('common.no_results') || 'No results found.'}</CommandEmpty>
{/* Navigation */}
<CommandGroup heading={t('nav.navigation') || 'Navigation'}>
{navigationItems.map((item) => (
<CommandItem
key={item.path}
value={item.label}
onSelect={() => runCommand(() => navigate(item.path))}
>
<item.icon className="mr-2 h-4 w-4 text-dark-400" />
<span>{item.label}</span>
</CommandItem>
))}
</CommandGroup>
{/* Actions */}
<CommandGroup heading={t('common.actions') || 'Actions'}>
{actionItems.map((item) => (
<CommandItem
key={item.label}
value={item.label}
onSelect={() => runCommand(item.action)}
>
<item.icon className="mr-2 h-4 w-4 text-dark-400" />
<span>{item.label}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
{/* Footer with keyboard hints */}
<div className="flex items-center justify-between border-t border-dark-700/50 px-3 py-2 text-xs text-dark-500">
<div className="flex items-center gap-2">
<kbd className="rounded bg-dark-800 px-1.5 py-0.5 font-mono text-dark-400">
</kbd>
<span>navigate</span>
</div>
<div className="flex items-center gap-2">
<kbd className="rounded bg-dark-800 px-1.5 py-0.5 font-mono text-dark-400">
</kbd>
<span>select</span>
</div>
<div className="flex items-center gap-2">
<kbd className="rounded bg-dark-800 px-1.5 py-0.5 font-mono text-dark-400">
esc
</kbd>
<span>close</span>
</div>
</div>
</Command>
</motion.div>
</DialogPrimitive.Content>
</>
)}
</AnimatePresence>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}

View File

@@ -0,0 +1 @@
export { CommandPalette } from './CommandPalette';

View File

@@ -0,0 +1,104 @@
import { Slot } from '@radix-ui/react-slot';
import { motion, type HTMLMotionProps } from 'framer-motion';
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { usePlatform } from '@/platform';
import { buttonTap, buttonHover, springTransition } from '../../motion/transitions';
import { buttonVariants, type ButtonVariants } from './Button.variants';
export interface ButtonProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'disabled'>, ButtonVariants {
children: ReactNode;
asChild?: boolean;
disabled?: boolean;
loading?: boolean;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
haptic?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
children,
className,
variant,
size,
fullWidth,
asChild = false,
disabled = false,
loading = false,
leftIcon,
rightIcon,
haptic = true,
onClick,
...props
},
ref,
) => {
const { haptic: platformHaptic } = usePlatform();
const isDisabled = disabled || loading;
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (haptic && !isDisabled) {
platformHaptic.impact('light');
}
onClick?.(e);
};
const classes = cn(buttonVariants({ variant, size, fullWidth }), className);
if (asChild) {
return (
<Slot ref={ref} className={classes} {...props}>
{children}
</Slot>
);
}
return (
<motion.button
ref={ref}
className={classes}
disabled={isDisabled}
onClick={handleClick}
whileHover={!isDisabled ? buttonHover : undefined}
whileTap={!isDisabled ? buttonTap : undefined}
transition={springTransition}
{...(props as HTMLMotionProps<'button'>)}
>
{loading ? (
<LoadingSpinner />
) : (
<>
{leftIcon && <span className="shrink-0">{leftIcon}</span>}
{children}
{rightIcon && <span className="shrink-0">{rightIcon}</span>}
</>
)}
</motion.button>
);
},
);
Button.displayName = 'Button';
function LoadingSpinner() {
return (
<svg
className="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
}
export { buttonVariants, type ButtonVariants };

View File

@@ -0,0 +1,64 @@
import { cva, type VariantProps } from 'class-variance-authority';
export const buttonVariants = cva(
// Base styles
[
'inline-flex items-center justify-center gap-2',
'font-medium transition-all duration-200',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-500/50 focus-visible:ring-offset-2 focus-visible:ring-offset-dark-950',
'disabled:pointer-events-none disabled:opacity-50',
'select-none',
],
{
variants: {
variant: {
primary: [
'bg-accent-500 text-white',
'hover:bg-accent-600',
'active:bg-accent-700',
'shadow-linear-sm hover:shadow-linear',
],
secondary: [
'bg-dark-800/80 text-dark-100',
'border border-dark-700/50',
'hover:bg-dark-700/80 hover:border-dark-600/50',
'active:bg-dark-800',
],
ghost: ['text-dark-300', 'hover:text-dark-100 hover:bg-dark-800/50', 'active:bg-dark-800'],
destructive: [
'bg-error-500/10 text-error-400',
'border border-error-500/20',
'hover:bg-error-500/20 hover:border-error-500/30',
'active:bg-error-500/30',
],
outline: [
'border border-dark-700/50 text-dark-200',
'hover:bg-dark-800/50 hover:border-dark-600/50 hover:text-dark-100',
'active:bg-dark-800',
],
link: [
'text-accent-400',
'hover:text-accent-300 hover:underline',
'active:text-accent-500',
],
},
size: {
sm: 'h-8 px-3 text-sm rounded-linear',
md: 'h-10 px-4 text-sm rounded-linear',
lg: 'h-12 px-6 text-base rounded-linear-lg',
icon: 'h-10 w-10 rounded-linear',
'icon-sm': 'h-8 w-8 rounded-linear',
'icon-lg': 'h-12 w-12 rounded-linear-lg',
},
fullWidth: {
true: 'w-full',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
},
);
export type ButtonVariants = VariantProps<typeof buttonVariants>;

View File

@@ -0,0 +1 @@
export { Button, type ButtonProps, buttonVariants, type ButtonVariants } from './Button';

View File

@@ -0,0 +1,172 @@
import { Command as CommandPrimitive } from 'cmdk';
import { forwardRef, type ComponentPropsWithoutRef, type HTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
// Search icon
const SearchIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-dark-400">
<path
d="M7.333 12.667A5.333 5.333 0 1 0 7.333 2a5.333 5.333 0 0 0 0 10.667ZM14 14l-2.9-2.9"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
// Root Command
export type CommandProps = ComponentPropsWithoutRef<typeof CommandPrimitive>;
export const Command = forwardRef<HTMLDivElement, CommandProps>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-linear-lg',
'bg-dark-900/95 text-dark-100 backdrop-blur-linear',
className,
)}
{...props}
/>
));
Command.displayName = 'Command';
// Input
export type CommandInputProps = ComponentPropsWithoutRef<typeof CommandPrimitive.Input>;
export const CommandInput = forwardRef<HTMLInputElement, CommandInputProps>(
({ className, ...props }, ref) => (
<div className="flex items-center border-b border-dark-700/50 px-3" cmdk-input-wrapper="">
<SearchIcon />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-12 w-full bg-transparent py-3 pl-2 text-sm text-dark-100',
'placeholder:text-dark-400',
'focus:outline-none',
'disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
),
);
CommandInput.displayName = 'CommandInput';
// List (scrollable area)
export type CommandListProps = ComponentPropsWithoutRef<typeof CommandPrimitive.List>;
export const CommandList = forwardRef<HTMLDivElement, CommandListProps>(
({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn(
'max-h-[300px] overflow-y-auto overflow-x-hidden',
'scrollbar-thin scrollbar-track-transparent scrollbar-thumb-dark-700',
className,
)}
{...props}
/>
),
);
CommandList.displayName = 'CommandList';
// Empty state
export type CommandEmptyProps = ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>;
export const CommandEmpty = forwardRef<HTMLDivElement, CommandEmptyProps>(
({ className, ...props }, ref) => (
<CommandPrimitive.Empty
ref={ref}
className={cn('py-6 text-center text-sm text-dark-400', className)}
{...props}
/>
),
);
CommandEmpty.displayName = 'CommandEmpty';
// Group
export type CommandGroupProps = ComponentPropsWithoutRef<typeof CommandPrimitive.Group>;
export const CommandGroup = forwardRef<HTMLDivElement, CommandGroupProps>(
({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-dark-100',
'[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5',
'[&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-dark-400',
className,
)}
{...props}
/>
),
);
CommandGroup.displayName = 'CommandGroup';
// Separator
export type CommandSeparatorProps = ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>;
export const CommandSeparator = forwardRef<HTMLDivElement, CommandSeparatorProps>(
({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-dark-700/50', className)}
{...props}
/>
),
);
CommandSeparator.displayName = 'CommandSeparator';
// Item
export type CommandItemProps = ComponentPropsWithoutRef<typeof CommandPrimitive.Item>;
export const CommandItem = forwardRef<HTMLDivElement, CommandItemProps>(
({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded-linear px-2 py-2',
'text-sm text-dark-200 outline-none',
'aria-selected:bg-dark-800/80 aria-selected:text-dark-100',
'data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
'transition-colors duration-150',
className,
)}
{...props}
/>
),
);
CommandItem.displayName = 'CommandItem';
// Shortcut display
export type CommandShortcutProps = HTMLAttributes<HTMLSpanElement>;
export const CommandShortcut = ({ className, ...props }: CommandShortcutProps) => (
<span className={cn('ml-auto text-xs tracking-widest text-dark-400', className)} {...props} />
);
CommandShortcut.displayName = 'CommandShortcut';
// Loading state
export type CommandLoadingProps = ComponentPropsWithoutRef<typeof CommandPrimitive.Loading>;
export const CommandLoading = forwardRef<HTMLDivElement, CommandLoadingProps>(
({ className, ...props }, ref) => (
<CommandPrimitive.Loading
ref={ref}
className={cn('py-6 text-center text-sm text-dark-400', className)}
{...props}
/>
),
);
CommandLoading.displayName = 'CommandLoading';

View File

@@ -0,0 +1,20 @@
export {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandSeparator,
CommandItem,
CommandShortcut,
CommandLoading,
type CommandProps,
type CommandInputProps,
type CommandListProps,
type CommandEmptyProps,
type CommandGroupProps,
type CommandSeparatorProps,
type CommandItemProps,
type CommandShortcutProps,
type CommandLoadingProps,
} from './Command';

View File

@@ -0,0 +1,190 @@
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { motion, AnimatePresence } from 'framer-motion';
import {
forwardRef,
type ComponentPropsWithoutRef,
createContext,
useContext,
useState,
} from 'react';
import { cn } from '@/lib/utils';
import { backdrop, backdropTransition, scale, scaleTransition } from '../../motion/transitions';
// Close icon
const CloseIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M12 4L4 12M4 4l8 8"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
// Context for AnimatePresence
const DialogContext = createContext<{ open: boolean }>({ open: false });
// Root
export type DialogProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Root>;
export const Dialog = ({ children, open, onOpenChange, ...props }: DialogProps) => {
const [internalOpen, setInternalOpen] = useState(false);
const isOpen = open !== undefined ? open : internalOpen;
const handleOpenChange = onOpenChange || setInternalOpen;
return (
<DialogPrimitive.Root open={isOpen} onOpenChange={handleOpenChange} {...props}>
<DialogContext.Provider value={{ open: isOpen }}>{children}</DialogContext.Provider>
</DialogPrimitive.Root>
);
};
// Trigger
export const DialogTrigger = DialogPrimitive.Trigger;
// Portal
export const DialogPortal = DialogPrimitive.Portal;
// Close
export const DialogClose = DialogPrimitive.Close;
// Overlay
export type DialogOverlayProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>;
export const DialogOverlay = forwardRef<HTMLDivElement, DialogOverlayProps>(
({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn('fixed inset-0 z-50 bg-black/60 backdrop-blur-sm', className)}
asChild
{...props}
>
<motion.div
variants={backdrop}
initial="initial"
animate="animate"
exit="exit"
transition={backdropTransition}
/>
</DialogPrimitive.Overlay>
),
);
DialogOverlay.displayName = 'DialogOverlay';
// Content
export interface DialogContentProps extends ComponentPropsWithoutRef<
typeof DialogPrimitive.Content
> {
showCloseButton?: boolean;
}
export const DialogContent = forwardRef<HTMLDivElement, DialogContentProps>(
({ className, children, showCloseButton = true, ...props }, ref) => {
const { open } = useContext(DialogContext);
return (
<DialogPortal forceMount>
<AnimatePresence mode="wait">
{open && (
<>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2',
'max-h-[85vh] w-full max-w-lg',
'grid gap-4 overflow-auto',
'rounded-linear-lg border border-dark-700/50 bg-dark-900/95 backdrop-blur-linear',
'p-6 shadow-linear-lg',
'focus:outline-none',
className,
)}
asChild
{...props}
>
<motion.div
variants={scale}
initial="initial"
animate="animate"
exit="exit"
transition={scaleTransition}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
className={cn(
'absolute right-4 top-4 rounded-linear p-1.5',
'text-dark-400 opacity-70 transition-all',
'hover:bg-dark-800/80 hover:opacity-100',
'focus:outline-none focus:ring-2 focus:ring-accent-500/50',
)}
>
<CloseIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</motion.div>
</DialogPrimitive.Content>
</>
)}
</AnimatePresence>
</DialogPortal>
);
},
);
DialogContent.displayName = 'DialogContent';
// Header
export type DialogHeaderProps = React.HTMLAttributes<HTMLDivElement>;
export const DialogHeader = ({ className, ...props }: DialogHeaderProps) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
);
DialogHeader.displayName = 'DialogHeader';
// Footer
export type DialogFooterProps = React.HTMLAttributes<HTMLDivElement>;
export const DialogFooter = ({ className, ...props }: DialogFooterProps) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
// Title
export type DialogTitleProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Title>;
export const DialogTitle = forwardRef<HTMLHeadingElement, DialogTitleProps>(
({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-dark-100', className)}
{...props}
/>
),
);
DialogTitle.displayName = 'DialogTitle';
// Description
export type DialogDescriptionProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Description>;
export const DialogDescription = forwardRef<HTMLParagraphElement, DialogDescriptionProps>(
({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-dark-400', className)}
{...props}
/>
),
);
DialogDescription.displayName = 'DialogDescription';

View File

@@ -0,0 +1,19 @@
export {
Dialog,
DialogTrigger,
DialogPortal,
DialogClose,
DialogOverlay,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
type DialogProps,
type DialogOverlayProps,
type DialogContentProps,
type DialogHeaderProps,
type DialogFooterProps,
type DialogTitleProps,
type DialogDescriptionProps,
} from './Dialog';

View File

@@ -0,0 +1,294 @@
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { motion } from 'framer-motion';
import { forwardRef, type ComponentPropsWithoutRef } from 'react';
import { cn } from '@/lib/utils';
import { usePlatform } from '@/platform';
import { dropdown, dropdownTransition } from '../../motion/transitions';
// Icons
const CheckIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M3.5 8.5L6.5 11.5L12.5 4.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
const ChevronRightIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M6 4l4 4-4 4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
const DotIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="3" fill="currentColor" />
</svg>
);
// Root
export const DropdownMenu = DropdownMenuPrimitive.Root;
// Trigger
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
// Group
export const DropdownMenuGroup = DropdownMenuPrimitive.Group;
// Portal
export const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
// Sub
export const DropdownMenuSub = DropdownMenuPrimitive.Sub;
// RadioGroup
export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
// SubTrigger
export interface DropdownMenuSubTriggerProps extends ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.SubTrigger
> {
inset?: boolean;
}
export const DropdownMenuSubTrigger = forwardRef<HTMLDivElement, DropdownMenuSubTriggerProps>(
({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-pointer select-none items-center gap-2 rounded-linear px-2 py-2',
'text-sm text-dark-200 outline-none',
'focus:bg-dark-800/80 focus:text-dark-100',
'data-[state=open]:bg-dark-800/80',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRightIcon />
</DropdownMenuPrimitive.SubTrigger>
),
);
DropdownMenuSubTrigger.displayName = 'DropdownMenuSubTrigger';
// SubContent
export type DropdownMenuSubContentProps = ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.SubContent
>;
export const DropdownMenuSubContent = forwardRef<HTMLDivElement, DropdownMenuSubContentProps>(
({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden',
'rounded-linear-lg border border-dark-700/50 bg-dark-900/95 backdrop-blur-linear',
'p-1 text-dark-100 shadow-linear-lg',
className,
)}
asChild
{...props}
>
<motion.div
variants={dropdown}
initial="initial"
animate="animate"
exit="exit"
transition={dropdownTransition}
/>
</DropdownMenuPrimitive.SubContent>
),
);
DropdownMenuSubContent.displayName = 'DropdownMenuSubContent';
// Content
export type DropdownMenuContentProps = ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Content
>;
export const DropdownMenuContent = forwardRef<HTMLDivElement, DropdownMenuContentProps>(
({ className, sideOffset = 4, ...props }, ref) => {
const { haptic } = usePlatform();
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden',
'rounded-linear-lg border border-dark-700/50 bg-dark-900/95 backdrop-blur-linear',
'p-1 text-dark-100 shadow-linear-lg',
className,
)}
onCloseAutoFocus={() => haptic.impact('light')}
asChild
{...props}
>
<motion.div
variants={dropdown}
initial="initial"
animate="animate"
exit="exit"
transition={dropdownTransition}
/>
</DropdownMenuPrimitive.Content>
</DropdownMenuPrimitive.Portal>
);
},
);
DropdownMenuContent.displayName = 'DropdownMenuContent';
// Item
export interface DropdownMenuItemProps extends ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Item
> {
inset?: boolean;
destructive?: boolean;
}
export const DropdownMenuItem = forwardRef<HTMLDivElement, DropdownMenuItemProps>(
({ className, inset, destructive, ...props }, ref) => {
const { haptic } = usePlatform();
return (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded-linear px-2 py-2',
'text-sm outline-none transition-colors duration-150',
destructive
? 'text-error-400 focus:bg-error-500/10 focus:text-error-300'
: 'text-dark-200 focus:bg-dark-800/80 focus:text-dark-100',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className,
)}
onClick={() => haptic.impact('light')}
{...props}
/>
);
},
);
DropdownMenuItem.displayName = 'DropdownMenuItem';
// CheckboxItem
export type DropdownMenuCheckboxItemProps = ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.CheckboxItem
>;
export const DropdownMenuCheckboxItem = forwardRef<HTMLDivElement, DropdownMenuCheckboxItemProps>(
({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center rounded-linear py-2 pl-8 pr-2',
'text-sm text-dark-200 outline-none transition-colors duration-150',
'focus:bg-dark-800/80 focus:text-dark-100',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-4 w-4 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
),
);
DropdownMenuCheckboxItem.displayName = 'DropdownMenuCheckboxItem';
// RadioItem
export type DropdownMenuRadioItemProps = ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.RadioItem
>;
export const DropdownMenuRadioItem = forwardRef<HTMLDivElement, DropdownMenuRadioItemProps>(
({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center rounded-linear py-2 pl-8 pr-2',
'text-sm text-dark-200 outline-none transition-colors duration-150',
'focus:bg-dark-800/80 focus:text-dark-100',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-4 w-4 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<DotIcon />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
),
);
DropdownMenuRadioItem.displayName = 'DropdownMenuRadioItem';
// Label
export interface DropdownMenuLabelProps extends ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Label
> {
inset?: boolean;
}
export const DropdownMenuLabel = forwardRef<HTMLDivElement, DropdownMenuLabelProps>(
({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-xs font-medium text-dark-400', inset && 'pl-8', className)}
{...props}
/>
),
);
DropdownMenuLabel.displayName = 'DropdownMenuLabel';
// Separator
export type DropdownMenuSeparatorProps = ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Separator
>;
export const DropdownMenuSeparator = forwardRef<HTMLDivElement, DropdownMenuSeparatorProps>(
({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-dark-700/50', className)}
{...props}
/>
),
);
DropdownMenuSeparator.displayName = 'DropdownMenuSeparator';
// Shortcut
export type DropdownMenuShortcutProps = React.HTMLAttributes<HTMLSpanElement>;
export const DropdownMenuShortcut = ({ className, ...props }: DropdownMenuShortcutProps) => (
<span className={cn('ml-auto text-xs tracking-widest text-dark-400', className)} {...props} />
);
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';

View File

@@ -0,0 +1,26 @@
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
type DropdownMenuContentProps,
type DropdownMenuItemProps,
type DropdownMenuCheckboxItemProps,
type DropdownMenuRadioItemProps,
type DropdownMenuLabelProps,
type DropdownMenuSeparatorProps,
type DropdownMenuShortcutProps,
type DropdownMenuSubTriggerProps,
type DropdownMenuSubContentProps,
} from './DropdownMenu';

View File

@@ -0,0 +1,96 @@
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { motion } from 'framer-motion';
import { forwardRef, type ComponentPropsWithoutRef } from 'react';
import { cn } from '@/lib/utils';
import { dropdown, dropdownTransition } from '../../motion/transitions';
// Close icon
const CloseIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M12 4L4 12M4 4l8 8"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
// Root
export const Popover = PopoverPrimitive.Root;
// Trigger
export const PopoverTrigger = PopoverPrimitive.Trigger;
// Anchor
export const PopoverAnchor = PopoverPrimitive.Anchor;
// Close
export const PopoverClose = PopoverPrimitive.Close;
// Content
export interface PopoverContentProps extends ComponentPropsWithoutRef<
typeof PopoverPrimitive.Content
> {
showCloseButton?: boolean;
}
export const PopoverContent = forwardRef<HTMLDivElement, PopoverContentProps>(
(
{ className, children, align = 'center', sideOffset = 4, showCloseButton = false, ...props },
ref,
) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 overflow-hidden',
'rounded-linear-lg border border-dark-700/50 bg-dark-900/95 backdrop-blur-linear',
'p-4 text-dark-100 shadow-linear-lg outline-none',
className,
)}
asChild
{...props}
>
<motion.div
variants={dropdown}
initial="initial"
animate="animate"
exit="exit"
transition={dropdownTransition}
>
{children}
{showCloseButton && (
<PopoverPrimitive.Close
className={cn(
'absolute right-2 top-2 rounded-linear p-1.5',
'text-dark-400 opacity-70 transition-all',
'hover:bg-dark-800/80 hover:opacity-100',
'focus:outline-none focus:ring-2 focus:ring-accent-500/50',
)}
>
<CloseIcon />
<span className="sr-only">Close</span>
</PopoverPrimitive.Close>
)}
</motion.div>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
),
);
PopoverContent.displayName = 'PopoverContent';
// Arrow
export type PopoverArrowProps = ComponentPropsWithoutRef<typeof PopoverPrimitive.Arrow>;
export const PopoverArrow = forwardRef<SVGSVGElement, PopoverArrowProps>(
({ className, ...props }, ref) => (
<PopoverPrimitive.Arrow ref={ref} className={cn('fill-dark-800', className)} {...props} />
),
);
PopoverArrow.displayName = 'PopoverArrow';

View File

@@ -0,0 +1,10 @@
export {
Popover,
PopoverTrigger,
PopoverAnchor,
PopoverClose,
PopoverContent,
PopoverArrow,
type PopoverContentProps,
type PopoverArrowProps,
} from './Popover';

View File

@@ -0,0 +1,183 @@
import * as SelectPrimitive from '@radix-ui/react-select';
import { motion } from 'framer-motion';
import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { usePlatform } from '@/platform';
import { dropdown, dropdownTransition } from '../../motion/transitions';
// Icons
const ChevronDownIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-dark-400">
<path
d="M4 6L8 10L12 6"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
const CheckIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-accent-400">
<path
d="M3.5 8.5L6.5 11.5L12.5 4.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
// Root
export const Select = SelectPrimitive.Root;
// Trigger
export interface SelectTriggerProps extends ComponentPropsWithoutRef<
typeof SelectPrimitive.Trigger
> {
placeholder?: string;
}
export const SelectTrigger = forwardRef<HTMLButtonElement, SelectTriggerProps>(
({ className, placeholder, ...props }, ref) => {
const { haptic } = usePlatform();
return (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between gap-2 rounded-linear px-3',
'border border-dark-700/50 bg-dark-800/80',
'text-sm text-dark-100 placeholder:text-dark-400',
'hover:border-dark-600/50 hover:bg-dark-700/80',
'focus:outline-none focus:ring-2 focus:ring-accent-500/50 focus:ring-offset-2 focus:ring-offset-dark-950',
'disabled:cursor-not-allowed disabled:opacity-50',
'transition-all duration-200',
'[&>span]:line-clamp-1',
className,
)}
onClick={() => haptic.impact('light')}
{...props}
>
<SelectPrimitive.Value placeholder={placeholder} />
<SelectPrimitive.Icon>
<ChevronDownIcon />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
},
);
SelectTrigger.displayName = 'SelectTrigger';
// Content
export type SelectContentProps = ComponentPropsWithoutRef<typeof SelectPrimitive.Content>;
export const SelectContent = forwardRef<HTMLDivElement, SelectContentProps>(
({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-80 min-w-[8rem] overflow-hidden',
'rounded-linear-lg border border-dark-700/50 bg-dark-900/95 backdrop-blur-linear',
'text-dark-100 shadow-linear-lg',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
asChild
{...props}
>
<motion.div
variants={dropdown}
initial="initial"
animate="animate"
exit="exit"
transition={dropdownTransition}
>
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
)}
>
{children}
</SelectPrimitive.Viewport>
</motion.div>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
),
);
SelectContent.displayName = 'SelectContent';
// Item
export interface SelectItemProps extends ComponentPropsWithoutRef<typeof SelectPrimitive.Item> {
children: ReactNode;
}
export const SelectItem = forwardRef<HTMLDivElement, SelectItemProps>(
({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-pointer select-none items-center rounded-linear py-2 pl-3 pr-8',
'text-sm text-dark-200 outline-none',
'hover:bg-dark-800/80 hover:text-dark-100',
'focus:bg-dark-800/80 focus:text-dark-100',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'data-[state=checked]:text-accent-400',
'transition-colors duration-150',
className,
)}
{...props}
>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
<span className="absolute right-2 flex h-4 w-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon />
</SelectPrimitive.ItemIndicator>
</span>
</SelectPrimitive.Item>
),
);
SelectItem.displayName = 'SelectItem';
// Group
export const SelectGroup = SelectPrimitive.Group;
// Label
export type SelectLabelProps = ComponentPropsWithoutRef<typeof SelectPrimitive.Label>;
export const SelectLabel = forwardRef<HTMLDivElement, SelectLabelProps>(
({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('px-3 py-1.5 text-xs font-medium text-dark-400', className)}
{...props}
/>
),
);
SelectLabel.displayName = 'SelectLabel';
// Separator
export type SelectSeparatorProps = ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>;
export const SelectSeparator = forwardRef<HTMLDivElement, SelectSeparatorProps>(
({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-dark-700/50', className)}
{...props}
/>
),
);
SelectSeparator.displayName = 'SelectSeparator';

View File

@@ -0,0 +1,14 @@
export {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectGroup,
SelectLabel,
SelectSeparator,
type SelectTriggerProps,
type SelectContentProps,
type SelectItemProps,
type SelectLabelProps,
type SelectSeparatorProps,
} from './Select';

View File

@@ -0,0 +1,284 @@
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { motion, AnimatePresence, useDragControls, PanInfo } from 'framer-motion';
import {
forwardRef,
type ComponentPropsWithoutRef,
createContext,
useContext,
useState,
useCallback,
} from 'react';
import { cn } from '@/lib/utils';
import { usePlatform } from '@/platform';
import { useBackButton } from '@/platform';
import {
backdrop,
backdropTransition,
sheetSlideUp,
sheetTransition,
} from '../../motion/transitions';
// Close icon
const CloseIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M12 4L4 12M4 4l8 8"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
// Context for AnimatePresence and control
interface SheetContextValue {
open: boolean;
onClose: () => void;
}
const SheetContext = createContext<SheetContextValue>({
open: false,
onClose: () => {},
});
// Root
export interface SheetProps extends ComponentPropsWithoutRef<typeof DialogPrimitive.Root> {
onClose?: () => void;
}
export const Sheet = ({ children, open, onOpenChange, onClose, ...props }: SheetProps) => {
const [internalOpen, setInternalOpen] = useState(false);
const isOpen = open !== undefined ? open : internalOpen;
const handleOpenChange = useCallback(
(newOpen: boolean) => {
if (onOpenChange) {
onOpenChange(newOpen);
} else {
setInternalOpen(newOpen);
}
if (!newOpen && onClose) {
onClose();
}
},
[onOpenChange, onClose],
);
const handleClose = useCallback(() => {
handleOpenChange(false);
}, [handleOpenChange]);
return (
<DialogPrimitive.Root open={isOpen} onOpenChange={handleOpenChange} {...props}>
<SheetContext.Provider value={{ open: isOpen, onClose: handleClose }}>
{children}
</SheetContext.Provider>
</DialogPrimitive.Root>
);
};
// Trigger
export const SheetTrigger = DialogPrimitive.Trigger;
// Portal
export const SheetPortal = DialogPrimitive.Portal;
// Close
export const SheetClose = DialogPrimitive.Close;
// Overlay
export type SheetOverlayProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>;
export const SheetOverlay = forwardRef<HTMLDivElement, SheetOverlayProps>(
({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn('fixed inset-0 z-50 bg-black/60 backdrop-blur-sm', className)}
asChild
{...props}
>
<motion.div
variants={backdrop}
initial="initial"
animate="animate"
exit="exit"
transition={backdropTransition}
/>
</DialogPrimitive.Overlay>
),
);
SheetOverlay.displayName = 'SheetOverlay';
// Content
export interface SheetContentProps extends ComponentPropsWithoutRef<
typeof DialogPrimitive.Content
> {
showDragHandle?: boolean;
showCloseButton?: boolean;
enableDragToClose?: boolean;
closeThreshold?: number;
}
export const SheetContent = forwardRef<HTMLDivElement, SheetContentProps>(
(
{
className,
children,
showDragHandle = true,
showCloseButton = false,
enableDragToClose = true,
closeThreshold = 0.3,
...props
},
ref,
) => {
const { open, onClose } = useContext(SheetContext);
const { haptic } = usePlatform();
const dragControls = useDragControls();
// Back button integration
useBackButton(open ? onClose : null);
const handleDragEnd = useCallback(
(_: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
const velocity = info.velocity.y;
const offset = info.offset.y;
const height = window.innerHeight;
// Close if dragged down fast enough or past threshold
if (velocity > 500 || offset > height * closeThreshold) {
haptic.impact('light');
onClose();
}
},
[closeThreshold, haptic, onClose],
);
return (
<SheetPortal forceMount>
<AnimatePresence mode="wait">
{open && (
<>
<SheetOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed inset-x-0 bottom-0 z-50',
'flex flex-col',
'max-h-[85vh]',
'rounded-t-2xl border-t border-dark-700/50 bg-dark-900/95 backdrop-blur-linear',
'shadow-linear-lg',
'focus:outline-none',
'pb-[env(safe-area-inset-bottom,0px)]',
className,
)}
asChild
{...props}
>
<motion.div
variants={sheetSlideUp}
initial="initial"
animate="animate"
exit="exit"
transition={sheetTransition}
drag={enableDragToClose ? 'y' : false}
dragControls={dragControls}
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.6 }}
onDragEnd={handleDragEnd}
>
{/* Drag handle */}
{showDragHandle && (
<div
className="flex cursor-grab justify-center pb-2 pt-3 active:cursor-grabbing"
onPointerDown={(e) => dragControls.start(e)}
>
<div className="h-1 w-10 rounded-full bg-dark-600" />
</div>
)}
{/* Close button */}
{showCloseButton && (
<DialogPrimitive.Close
className={cn(
'absolute right-4 top-4 rounded-linear p-1.5',
'text-dark-400 opacity-70 transition-all',
'hover:bg-dark-800/80 hover:opacity-100',
'focus:outline-none focus:ring-2 focus:ring-accent-500/50',
)}
>
<CloseIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
{/* Content */}
<div className="flex-1 overflow-y-auto px-4 pb-4">{children}</div>
</motion.div>
</DialogPrimitive.Content>
</>
)}
</AnimatePresence>
</SheetPortal>
);
},
);
SheetContent.displayName = 'SheetContent';
// Header
export type SheetHeaderProps = React.HTMLAttributes<HTMLDivElement>;
export const SheetHeader = ({ className, ...props }: SheetHeaderProps) => (
<div
className={cn('flex flex-col space-y-1.5 px-2 pt-2 text-center sm:text-left', className)}
{...props}
/>
);
SheetHeader.displayName = 'SheetHeader';
// Footer
export type SheetFooterProps = React.HTMLAttributes<HTMLDivElement>;
export const SheetFooter = ({ className, ...props }: SheetFooterProps) => (
<div
className={cn('flex flex-col gap-2 px-2 pt-4 sm:flex-row sm:justify-end', className)}
{...props}
/>
);
SheetFooter.displayName = 'SheetFooter';
// Title
export type SheetTitleProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Title>;
export const SheetTitle = forwardRef<HTMLHeadingElement, SheetTitleProps>(
({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-dark-100', className)}
{...props}
/>
),
);
SheetTitle.displayName = 'SheetTitle';
// Description
export type SheetDescriptionProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Description>;
export const SheetDescription = forwardRef<HTMLParagraphElement, SheetDescriptionProps>(
({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-dark-400', className)}
{...props}
/>
),
);
SheetDescription.displayName = 'SheetDescription';

View File

@@ -0,0 +1,19 @@
export {
Sheet,
SheetTrigger,
SheetPortal,
SheetClose,
SheetOverlay,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
type SheetProps,
type SheetOverlayProps,
type SheetContentProps,
type SheetHeaderProps,
type SheetFooterProps,
type SheetTitleProps,
type SheetDescriptionProps,
} from './Sheet';

View File

@@ -0,0 +1,73 @@
import * as SwitchPrimitive from '@radix-ui/react-switch';
import { motion } from 'framer-motion';
import { forwardRef, type ComponentPropsWithoutRef } from 'react';
import { cn } from '@/lib/utils';
import { usePlatform } from '@/platform';
import { springTransition } from '../../motion/transitions';
export interface SwitchProps extends Omit<
ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>,
'onChange'
> {
label?: string;
description?: string;
onChange?: (checked: boolean) => void;
haptic?: boolean;
}
export const Switch = forwardRef<HTMLButtonElement, SwitchProps>(
({ className, label, description, onChange, onCheckedChange, haptic = true, ...props }, ref) => {
const { haptic: platformHaptic } = usePlatform();
const handleCheckedChange = (checked: boolean) => {
if (haptic) {
platformHaptic.impact('light');
}
onCheckedChange?.(checked);
onChange?.(checked);
};
const switchElement = (
<SwitchPrimitive.Root
ref={ref}
className={cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full',
'border-2 border-transparent transition-colors duration-200',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-500/50 focus-visible:ring-offset-2 focus-visible:ring-offset-dark-950',
'disabled:cursor-not-allowed disabled:opacity-50',
'data-[state=checked]:bg-accent-500 data-[state=unchecked]:bg-dark-700',
className,
)}
onCheckedChange={handleCheckedChange}
{...props}
>
<SwitchPrimitive.Thumb asChild>
<motion.span
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-white shadow-lg ring-0',
'data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0',
)}
layout
transition={springTransition}
/>
</SwitchPrimitive.Thumb>
</SwitchPrimitive.Root>
);
if (!label) {
return switchElement;
}
return (
<label className="flex cursor-pointer items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<span className="block text-sm font-medium text-dark-100">{label}</span>
{description && <span className="mt-0.5 block text-sm text-dark-400">{description}</span>}
</div>
{switchElement}
</label>
);
},
);
Switch.displayName = 'Switch';

View File

@@ -0,0 +1 @@
export { Switch, type SwitchProps } from './Switch';

View File

@@ -0,0 +1,75 @@
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { motion } from 'framer-motion';
import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { tooltip, tooltipTransition } from '../../motion/transitions';
// Provider - wrap your app with this
export const TooltipProvider = TooltipPrimitive.Provider;
// Root
export const Tooltip = TooltipPrimitive.Root;
// Trigger
export const TooltipTrigger = TooltipPrimitive.Trigger;
// Content
export type TooltipContentProps = ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>;
export const TooltipContent = forwardRef<HTMLDivElement, TooltipContentProps>(
({ className, sideOffset = 4, children, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden',
'rounded-linear px-3 py-1.5',
'border border-dark-700/50 bg-dark-800',
'text-xs text-dark-100 shadow-linear',
className,
)}
asChild
{...props}
>
<motion.div
variants={tooltip}
initial="initial"
animate="animate"
exit="exit"
transition={tooltipTransition}
>
{children}
</motion.div>
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
),
);
TooltipContent.displayName = 'TooltipContent';
// Convenience wrapper component
export interface SimpleTooltipProps {
content: ReactNode;
children: ReactNode;
side?: 'top' | 'right' | 'bottom' | 'left';
align?: 'start' | 'center' | 'end';
delayDuration?: number;
className?: string;
}
export const SimpleTooltip = ({
content,
children,
side = 'top',
align = 'center',
delayDuration = 200,
className,
}: SimpleTooltipProps) => (
<Tooltip delayDuration={delayDuration}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side={side} align={align} className={className}>
{content}
</TooltipContent>
</Tooltip>
);

View File

@@ -0,0 +1,9 @@
export {
TooltipProvider,
Tooltip,
TooltipTrigger,
TooltipContent,
SimpleTooltip,
type TooltipContentProps,
type SimpleTooltipProps,
} from './Tooltip';

View File

@@ -0,0 +1,10 @@
// Re-export all primitives from a single entry point
export * from './Button';
export * from './Command';
export * from './Dialog';
export * from './DropdownMenu';
export * from './Popover';
export * from './Select';
export * from './Sheet';
export * from './Switch';
export * from './Tooltip';

View File

@@ -1,5 +1,6 @@
import { Link } from 'react-router-dom';
import { forwardRef } from 'react';
import { forwardRef, useCallback } from 'react';
import { useHaptic } from '@/platform';
export type BentoSize = 'sm' | 'md' | 'lg' | 'xl';
@@ -63,6 +64,7 @@ const glowClasses = `
export const BentoCard = forwardRef<HTMLDivElement, BentoCardProps>((props, ref) => {
const { size = 'sm', children, className = '', hover = false, glow = false } = props;
const haptic = useHaptic();
const classes = [
baseClasses,
@@ -74,10 +76,27 @@ export const BentoCard = forwardRef<HTMLDivElement, BentoCardProps>((props, ref)
.filter(Boolean)
.join(' ');
// Wrap click handlers to trigger haptic feedback when hover is enabled
const withHaptic = useCallback(
(onClick?: () => void) => {
if (!hover || !onClick) return onClick;
return () => {
haptic.impact('light');
onClick();
};
},
[hover, haptic],
);
if (props.as === 'link') {
const { to, state } = props as BentoCardLinkProps;
return (
<Link to={to} state={state} className={classes}>
<Link
to={to}
state={state}
className={classes}
onClick={hover ? () => haptic.impact('light') : undefined}
>
{children}
</Link>
);
@@ -89,7 +108,7 @@ export const BentoCard = forwardRef<HTMLDivElement, BentoCardProps>((props, ref)
<button
ref={ref as React.Ref<HTMLButtonElement>}
type={type}
onClick={onClick}
onClick={withHaptic(onClick)}
disabled={disabled}
className={classes}
>
@@ -100,7 +119,7 @@ export const BentoCard = forwardRef<HTMLDivElement, BentoCardProps>((props, ref)
const { onClick } = props as BentoCardDivProps;
return (
<div ref={ref} onClick={onClick} className={classes}>
<div ref={ref} onClick={withHaptic(onClick)} className={classes}>
{children}
</div>
);

396
src/components/ui/Sheet.tsx Normal file
View File

@@ -0,0 +1,396 @@
import { useEffect, useRef, useCallback, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useBackButton } from '@/platform';
import { useHaptic } from '@/platform';
export interface SheetProps {
isOpen: boolean;
onClose: () => void;
/**
* Snap points as fractions of viewport height (0-1)
* @default [1] - Full height
*/
snapPoints?: number[];
/**
* Initial snap point index
* @default 0
*/
initialSnap?: number;
/**
* Whether to close when dragged below threshold
* @default true
*/
closeOnDragDown?: boolean;
/**
* Threshold to close (fraction of sheet height)
* @default 0.3
*/
closeThreshold?: number;
/**
* Whether to show drag handle
* @default true
*/
showHandle?: boolean;
/**
* Whether backdrop click closes sheet
* @default true
*/
closeOnBackdropClick?: boolean;
/**
* Whether Escape key closes sheet
* @default true
*/
closeOnEscape?: boolean;
/**
* Custom class for the sheet container
*/
className?: string;
/**
* Custom class for the content area
*/
contentClassName?: string;
/**
* Title shown in the header
*/
title?: string;
/**
* Children content
*/
children: ReactNode;
}
interface DragState {
startY: number;
currentY: number;
startHeight: number;
isDragging: boolean;
velocity: number;
lastY: number;
lastTime: number;
}
const VELOCITY_THRESHOLD = 0.5; // px/ms - fast swipe closes regardless of position
const ANIMATION_DURATION = 300;
export function Sheet({
isOpen,
onClose,
snapPoints = [1],
initialSnap = 0,
closeOnDragDown = true,
closeThreshold = 0.3,
showHandle = true,
closeOnBackdropClick = true,
closeOnEscape = true,
className = '',
contentClassName = '',
title,
children,
}: SheetProps) {
const sheetRef = useRef<HTMLDivElement>(null);
const dragState = useRef<DragState>({
startY: 0,
currentY: 0,
startHeight: 0,
isDragging: false,
velocity: 0,
lastY: 0,
lastTime: 0,
});
const [currentSnapIndex, setCurrentSnapIndex] = useState(initialSnap);
const [isAnimating, setIsAnimating] = useState(false);
const [translateY, setTranslateY] = useState(0);
const [isVisible, setIsVisible] = useState(false);
const haptic = useHaptic();
// BackButton integration
useBackButton(isOpen ? onClose : null);
// Calculate current height based on snap point
const currentHeight = `${snapPoints[currentSnapIndex] * 100}vh`;
// Handle keyboard events
useEffect(() => {
if (!isOpen || !closeOnEscape) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, closeOnEscape, onClose]);
// Handle body scroll lock
useEffect(() => {
if (isOpen) {
const scrollY = window.scrollY;
document.body.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.top = `-${scrollY}px`;
document.body.style.width = '100%';
return () => {
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.top = '';
document.body.style.width = '';
window.scrollTo(0, scrollY);
};
}
}, [isOpen]);
// Animation on open/close
useEffect(() => {
if (isOpen) {
// Small delay to ensure portal is mounted
requestAnimationFrame(() => {
setIsVisible(true);
});
} else {
setIsVisible(false);
}
}, [isOpen]);
// Find closest snap point
const findClosestSnap = useCallback(
(currentTranslate: number, velocity: number): number => {
const viewportHeight = window.innerHeight;
const sheetHeight = sheetRef.current?.offsetHeight ?? viewportHeight;
// If velocity is high enough, snap in direction of movement
if (Math.abs(velocity) > VELOCITY_THRESHOLD) {
if (velocity > 0) {
// Swiping down - go to lower snap or close
if (closeOnDragDown && currentTranslate > sheetHeight * 0.1) {
return -1; // Close
}
return Math.min(currentSnapIndex + 1, snapPoints.length - 1);
} else {
// Swiping up - go to higher snap
return Math.max(currentSnapIndex - 1, 0);
}
}
// Otherwise, find closest snap based on position
const currentPosition = currentTranslate / viewportHeight;
// Check if should close
if (closeOnDragDown && currentPosition > closeThreshold) {
return -1; // Close
}
// Find closest snap point
let closestIndex = currentSnapIndex;
let minDistance = Infinity;
snapPoints.forEach((snap, index) => {
const snapPosition = (1 - snap) * viewportHeight;
const distance = Math.abs(currentTranslate - snapPosition);
if (distance < minDistance) {
minDistance = distance;
closestIndex = index;
}
});
return closestIndex;
},
[currentSnapIndex, snapPoints, closeOnDragDown, closeThreshold],
);
// Handle drag start
const handleDragStart = useCallback(
(clientY: number) => {
if (isAnimating) return;
const state = dragState.current;
state.startY = clientY;
state.currentY = clientY;
state.startHeight = sheetRef.current?.offsetHeight ?? 0;
state.isDragging = true;
state.velocity = 0;
state.lastY = clientY;
state.lastTime = Date.now();
},
[isAnimating],
);
// Handle drag move
const handleDragMove = useCallback((clientY: number) => {
const state = dragState.current;
if (!state.isDragging) return;
const deltaY = clientY - state.startY;
const now = Date.now();
const timeDelta = now - state.lastTime;
// Calculate velocity
if (timeDelta > 0) {
state.velocity = (clientY - state.lastY) / timeDelta;
}
state.lastY = clientY;
state.lastTime = now;
state.currentY = clientY;
// Only allow dragging down (positive translateY)
const newTranslateY = Math.max(0, deltaY);
setTranslateY(newTranslateY);
}, []);
// Handle drag end
const handleDragEnd = useCallback(() => {
const state = dragState.current;
if (!state.isDragging) return;
state.isDragging = false;
setIsAnimating(true);
const targetSnap = findClosestSnap(translateY, state.velocity);
if (targetSnap === -1) {
// Close the sheet
haptic.notification('warning');
setTranslateY(window.innerHeight);
setTimeout(() => {
onClose();
setTranslateY(0);
setIsAnimating(false);
}, ANIMATION_DURATION);
} else {
// Snap to position
if (targetSnap !== currentSnapIndex) {
haptic.impact('light');
}
setCurrentSnapIndex(targetSnap);
setTranslateY(0);
setTimeout(() => {
setIsAnimating(false);
}, ANIMATION_DURATION);
}
}, [translateY, findClosestSnap, currentSnapIndex, haptic, onClose]);
// Touch event handlers
const handleTouchStart = useCallback(
(e: React.TouchEvent) => {
handleDragStart(e.touches[0].clientY);
},
[handleDragStart],
);
const handleTouchMove = useCallback(
(e: React.TouchEvent) => {
handleDragMove(e.touches[0].clientY);
},
[handleDragMove],
);
const handleTouchEnd = useCallback(() => {
handleDragEnd();
}, [handleDragEnd]);
// Mouse event handlers (for desktop)
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
handleDragStart(e.clientY);
const handleMouseMove = (e: MouseEvent) => {
handleDragMove(e.clientY);
};
const handleMouseUp = () => {
handleDragEnd();
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
},
[handleDragStart, handleDragMove, handleDragEnd],
);
// Backdrop click handler
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (closeOnBackdropClick && e.target === e.currentTarget) {
haptic.impact('light');
onClose();
}
},
[closeOnBackdropClick, haptic, onClose],
);
if (!isOpen) return null;
const sheet = (
<div
className={`fixed inset-0 z-50 flex items-end justify-center ${
isVisible ? 'opacity-100' : 'opacity-0'
} transition-opacity duration-300`}
onClick={handleBackdropClick}
>
{/* Backdrop */}
<div
className={`absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-300 ${
isVisible ? 'opacity-100' : 'opacity-0'
}`}
/>
{/* Sheet */}
<div
ref={sheetRef}
className={`relative w-full max-w-lg overflow-hidden rounded-t-3xl bg-dark-900 shadow-2xl ${
isAnimating ? 'transition-transform duration-300 ease-out' : ''
} ${isVisible ? 'translate-y-0' : 'translate-y-full'} ${className}`}
style={{
maxHeight: currentHeight,
transform: `translateY(${isVisible ? translateY : '100%'}px)`,
paddingBottom: 'env(safe-area-inset-bottom)',
}}
>
{/* Drag handle area */}
{showHandle && (
<div
className="flex cursor-grab touch-none items-center justify-center py-3 active:cursor-grabbing"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onMouseDown={handleMouseDown}
>
<div className="h-1 w-10 rounded-full bg-dark-600" />
</div>
)}
{/* Title */}
{title && (
<div className="border-b border-dark-700/50 px-6 pb-4">
<h2 className="text-lg font-semibold text-dark-100">{title}</h2>
</div>
)}
{/* Content */}
<div
className={`overflow-y-auto overscroll-contain ${contentClassName}`}
style={{
maxHeight: `calc(${currentHeight} - ${showHandle ? '44px' : '0px'} - ${title ? '60px' : '0px'})`,
}}
>
{children}
</div>
</div>
</div>
);
return createPortal(sheet, document.body);
}
// Light theme styles applied via CSS
// Add to globals.css:
// .light .sheet-backdrop { @apply bg-black/40; }
// .light .sheet-container { @apply bg-champagne-100; }

View File

@@ -0,0 +1,199 @@
import { type CSSProperties } from 'react';
export type SkeletonVariant = 'text' | 'avatar' | 'card' | 'list' | 'bento';
export interface SkeletonProps {
/**
* Variant of skeleton
* - text: Single line of text
* - avatar: Circular avatar
* - card: Full card with header and content
* - list: Multiple list items
* - bento: Bento card style (original BentoSkeleton)
*/
variant?: SkeletonVariant;
/**
* Number of skeleton items to render
*/
count?: number;
/**
* Width (for text variant)
*/
width?: string | number;
/**
* Height (for custom sizing)
*/
height?: string | number;
/**
* Additional CSS classes
*/
className?: string;
/**
* Whether to animate
* @default true
*/
animate?: boolean;
}
const baseClasses = 'bg-dark-800/50 rounded';
const animateClasses = 'animate-pulse';
export function Skeleton({
variant = 'text',
count = 1,
width,
height,
className = '',
animate = true,
}: SkeletonProps) {
const animation = animate ? animateClasses : '';
const renderSkeleton = (index: number) => {
const style: CSSProperties = {
'--stagger': index,
width: width,
height: height,
} as CSSProperties;
switch (variant) {
case 'text':
return (
<div
key={index}
className={`${baseClasses} ${animation} h-4 ${className}`}
style={{ ...style, width: width ?? '100%' }}
/>
);
case 'avatar':
return (
<div
key={index}
className={`${baseClasses} ${animation} rounded-full ${className}`}
style={{ ...style, width: width ?? 40, height: height ?? 40 }}
/>
);
case 'card':
return (
<div
key={index}
className={`${baseClasses} ${animation} rounded-[var(--bento-radius,24px)] border border-dark-700/30 p-4 ${className}`}
style={style}
>
{/* Header */}
<div className="mb-4 flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-dark-700/50" />
<div className="flex-1 space-y-2">
<div className="h-4 w-3/4 rounded bg-dark-700/50" />
<div className="h-3 w-1/2 rounded bg-dark-700/50" />
</div>
</div>
{/* Content */}
<div className="space-y-2">
<div className="h-3 w-full rounded bg-dark-700/50" />
<div className="h-3 w-5/6 rounded bg-dark-700/50" />
<div className="h-3 w-4/6 rounded bg-dark-700/50" />
</div>
</div>
);
case 'list':
return (
<div
key={index}
className={`${baseClasses} ${animation} flex items-center gap-3 p-3 ${className}`}
style={style}
>
<div className="h-10 w-10 shrink-0 rounded-lg bg-dark-700/50" />
<div className="flex-1 space-y-2">
<div className="h-4 w-3/4 rounded bg-dark-700/50" />
<div className="h-3 w-1/2 rounded bg-dark-700/50" />
</div>
</div>
);
case 'bento':
default:
return (
<div
key={index}
className={`${baseClasses} ${animation} min-h-[160px] w-full rounded-[var(--bento-radius,24px)] border border-dark-700/30 ${className}`}
style={style}
/>
);
}
};
if (count > 1) {
return <>{Array.from({ length: count }).map((_, i) => renderSkeleton(i))}</>;
}
return renderSkeleton(0);
}
// Convenience components for common use cases
export function TextSkeleton({
lines = 1,
className = '',
lastLineWidth = '60%',
}: {
lines?: number;
className?: string;
lastLineWidth?: string;
}) {
return (
<div className={`space-y-2 ${className}`}>
{Array.from({ length: lines }).map((_, i) => (
<Skeleton key={i} variant="text" width={i === lines - 1 ? lastLineWidth : '100%'} />
))}
</div>
);
}
export function AvatarSkeleton({
size = 40,
className = '',
}: {
size?: number;
className?: string;
}) {
return <Skeleton variant="avatar" width={size} height={size} className={className} />;
}
export function CardSkeleton({
count = 1,
className = '',
}: {
count?: number;
className?: string;
}) {
return <Skeleton variant="card" count={count} className={className} />;
}
export function ListSkeleton({
count = 3,
className = '',
}: {
count?: number;
className?: string;
}) {
return (
<div className={`space-y-2 ${className}`}>
<Skeleton variant="list" count={count} />
</div>
);
}
export function BentoSkeleton({
count = 1,
className = '',
}: {
count?: number;
className?: string;
}) {
return <Skeleton variant="bento" count={count} className={className} />;
}
// Default export for backwards compatibility
export default BentoSkeleton;