refactor: full codebase cleanup, dependency updates, and lint fixes

Phase 0: Remove ~920 lines of dead code (ThemeBentoPicker, PromoDiscountBadge,
AdminLayout, SettingsSidebar, MovingGradient, EmptyState, miniapp API, unused
types/functions/transitions/skeleton helpers). Fix API barrel file (add 20 missing exports).

Phase 1: Add ErrorBoundary (app/page/widget levels), centralize constants,
add axios timeout, fix staleTime:0 in React Query.

Phase 2: Consolidate hexToHsl, extract email validation, fix duplicate code.

Phase 3: Fix auth race condition (await checkAdminStatus), memoize useCurrency,
add WebSocket message validation.

Phase 4: Extract useBranding, useFeatureFlags, useScrollRestoration from AppShell.

Phase 5: Remove all eslint-disable react-hooks/exhaustive-deps (14 total),
simplify logger, remove deprecated hooks (useBackButton, useTelegramDnd,
useTelegramWebApp).

Dependencies: React 19, react-router 7, zustand 5, i18next 25, react-i18next 16,
eslint-plugin-react-refresh 0.5. Remove unused @lottiefiles/dotlottie-react.
Convert vite manualChunks to function-based approach for react-router v7 compat.

Lint: Fix logger.ts no-unused-expressions, fix react-refresh/only-export-components
in 6 Radix primitive files (const re-exports → direct re-exports), fix
WebSocketProvider exhaustive-deps. Result: 0 errors, 0 warnings.

Add CLAUDE.md to .gitignore.
This commit is contained in:
c0mrade
2026-02-06 01:35:12 +03:00
parent c5cad20a6f
commit 562ab7abf7
118 changed files with 1243 additions and 2746 deletions

View File

@@ -3,9 +3,10 @@ import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { authApi } from '../api/auth';
import { isValidEmail } from '../utils/validation';
import { UI } from '../config/constants';
import { useAuthStore } from '../store/auth';
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
import { useBackButton } from '@/platform';
import { useTelegramSDK } from '../hooks/useTelegramSDK';
// Icons
const CloseIcon = () => (
@@ -50,13 +51,13 @@ function useIsMobile() {
return isMobile;
}
const RESEND_COOLDOWN = 60; // seconds
const RESEND_COOLDOWN = UI.RESEND_COOLDOWN_SEC;
export default function ChangeEmailModal({ onClose, currentEmail }: ChangeEmailModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { setUser } = useAuthStore();
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp();
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset } = useTelegramSDK();
const isMobileScreen = useIsMobile();
const emailInputRef = useRef<HTMLInputElement>(null);
@@ -88,9 +89,6 @@ export default function ChangeEmailModal({ onClose, currentEmail }: ChangeEmailM
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleClose]);
// Telegram back button - using platform hook
useBackButton(handleClose);
// Scroll lock
useEffect(() => {
const scrollY = window.scrollY;
@@ -181,18 +179,13 @@ export default function ChangeEmailModal({ onClose, currentEmail }: ChangeEmailM
},
});
const validateEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email.trim());
};
const handleSendCode = () => {
setError(null);
if (!newEmail.trim()) {
setError(t('profile.emailRequired'));
return;
}
if (!validateEmail(newEmail)) {
if (!isValidEmail(newEmail.trim())) {
setError(t('profile.invalidEmail'));
return;
}

View File

@@ -0,0 +1,93 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
interface ErrorBoundaryProps {
children: ReactNode;
level?: 'app' | 'page' | 'widget';
onReset?: () => void;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('[ErrorBoundary]', error, errorInfo);
}
handleReset = () => {
this.setState({ hasError: false, error: null });
this.props.onReset?.();
};
render() {
if (!this.state.hasError) {
return this.props.children;
}
const { level = 'page' } = this.props;
if (level === 'app') {
return (
<div className="flex min-h-screen items-center justify-center bg-dark-900 p-4">
<div className="max-w-md text-center">
<div className="mb-4 text-4xl"></div>
<h1 className="mb-2 text-xl font-bold text-dark-50">Something went wrong</h1>
<p className="mb-6 text-dark-400">
An unexpected error occurred. Please try reloading the page.
</p>
<button
onClick={() => window.location.reload()}
className="rounded-xl bg-accent-500 px-6 py-3 font-medium text-white transition-colors hover:bg-accent-600"
>
Reload page
</button>
</div>
</div>
);
}
if (level === 'widget') {
return (
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-4 text-center">
<p className="text-sm text-error-400">Failed to load this section</p>
<button
onClick={this.handleReset}
className="mt-2 text-sm text-accent-400 hover:text-accent-300"
>
Try again
</button>
</div>
);
}
// level === 'page' (default)
return (
<div className="flex min-h-[50vh] items-center justify-center p-4">
<div className="max-w-md text-center">
<div className="mb-4 text-4xl"></div>
<h1 className="mb-2 text-xl font-bold text-dark-50">Something went wrong</h1>
<p className="mb-6 text-sm text-dark-400">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
<button
onClick={this.handleReset}
className="rounded-xl bg-accent-500 px-6 py-3 font-medium text-white transition-colors hover:bg-accent-600"
>
Try again
</button>
</div>
</div>
);
}
}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useNavigate, useLocation } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useCurrency } from '../hooks/useCurrency';

View File

@@ -1,385 +0,0 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { promoApi } from '../api/promo';
const SparklesIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<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.09z"
/>
</svg>
);
const ClockIcon = () => (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
const XCircleIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
const WarningIcon = () => (
<svg
className="h-12 w-12"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
);
const formatTimeLeft = (expiresAt: string, t: (key: string) => string): string => {
const now = new Date();
// Ensure UTC parsing - if no timezone specified, assume UTC
let expires: Date;
if (expiresAt.includes('Z') || expiresAt.includes('+') || expiresAt.includes('-', 10)) {
expires = new Date(expiresAt);
} else {
// No timezone - treat as UTC
expires = new Date(expiresAt + 'Z');
}
const diffMs = expires.getTime() - now.getTime();
if (diffMs <= 0) return '';
const hours = Math.floor(diffMs / (1000 * 60 * 60));
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 24) {
const days = Math.floor(hours / 24);
return `${days}${t('promo.time.days')}`;
}
if (hours > 0) {
return `${hours}${t('promo.time.hours')} ${minutes}${t('promo.time.minutes')}`;
}
return `${minutes}${t('promo.time.minutes')}`;
};
// ------- Confirmation Modal -------
interface DeactivateConfirmModalProps {
isOpen: boolean;
discountPercent: number;
isDeactivating: boolean;
onConfirm: () => void;
onCancel: () => void;
}
function DeactivateConfirmModal({
isOpen,
discountPercent,
isDeactivating,
onConfirm,
onCancel,
}: DeactivateConfirmModalProps) {
const { t } = useTranslation();
// Escape key to close
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !isDeactivating) {
e.preventDefault();
onCancel();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, isDeactivating, onCancel]);
// Scroll lock
useEffect(() => {
if (!isOpen) return;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
if (!isOpen) return null;
const modalContent = (
<div
className="fixed inset-0 z-[100] flex items-center justify-center"
role="dialog"
aria-modal="true"
aria-labelledby="deactivate-modal-title"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
onClick={isDeactivating ? undefined : onCancel}
/>
{/* Modal */}
<div
className="relative mx-4 w-full max-w-sm overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-900 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Warning header */}
<div className="flex flex-col items-center bg-gradient-to-br from-warning-500/20 to-red-500/20 px-6 pb-6 pt-8">
<div className="mb-3 text-warning-400">
<WarningIcon />
</div>
<h2 id="deactivate-modal-title" className="text-center text-lg font-bold text-dark-100">
{t('promo.deactivate.confirmTitle')}
</h2>
<p className="mt-2 text-center text-sm text-dark-300">
{t('promo.deactivate.confirmDescription', { percent: discountPercent })}
</p>
</div>
{/* Warning text */}
<div className="px-6 py-4">
<div className="rounded-lg border border-warning-500/20 bg-warning-500/10 px-4 py-3">
<p className="text-center text-sm text-warning-400">{t('promo.deactivate.warning')}</p>
</div>
</div>
{/* Action buttons */}
<div className="flex gap-3 px-6 pb-6">
<button
onClick={onCancel}
disabled={isDeactivating}
className="flex-1 rounded-xl bg-dark-800 py-3 font-semibold text-dark-300 transition-colors hover:bg-dark-700 hover:text-dark-100 disabled:opacity-50"
>
{t('common.cancel')}
</button>
<button
onClick={onConfirm}
disabled={isDeactivating}
className="flex-1 rounded-xl bg-gradient-to-r from-red-500 to-red-600 py-3 font-semibold text-white shadow-lg shadow-red-500/25 transition-all hover:from-red-400 hover:to-red-500 active:from-red-600 active:to-red-700 disabled:opacity-50"
>
{isDeactivating ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
{t('promo.deactivate.deactivating')}
</span>
) : (
t('promo.deactivate.confirm')
)}
</button>
</div>
</div>
</div>
);
if (typeof document !== 'undefined') {
return createPortal(modalContent, document.body);
}
return modalContent;
}
// ------- Main Component -------
export default function PromoDiscountBadge() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [isOpen, setIsOpen] = useState(false);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [deactivateError, setDeactivateError] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const { data: activeDiscount } = useQuery({
queryKey: ['active-discount'],
queryFn: promoApi.getActiveDiscount,
staleTime: 30000,
refetchInterval: 60000, // Refresh every minute
});
const deactivateMutation = useMutation({
mutationFn: async () => {
const source = activeDiscount?.source;
if (source && source.startsWith('promocode:')) {
await promoApi.deactivateDiscount();
} else {
await promoApi.clearActiveDiscount();
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['active-discount'] });
queryClient.invalidateQueries({ queryKey: ['promo-offers'] });
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
setShowConfirmModal(false);
setIsOpen(false);
setDeactivateError(null);
},
onError: (error: unknown) => {
const axiosErr = error as { response?: { data?: { detail?: string } } };
setDeactivateError(axiosErr.response?.data?.detail || t('promo.deactivate.error'));
},
});
const handleCloseConfirmModal = useCallback(() => {
if (!deactivateMutation.isPending) {
setShowConfirmModal(false);
setDeactivateError(null);
}
}, [deactivateMutation.isPending]);
const handleConfirmDeactivate = useCallback(() => {
setDeactivateError(null);
deactivateMutation.mutate();
}, [deactivateMutation]);
// Close dropdown on click outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Don't render if no active discount
if (!activeDiscount || !activeDiscount.is_active || !activeDiscount.discount_percent) {
return null;
}
const timeLeft = activeDiscount.expires_at ? formatTimeLeft(activeDiscount.expires_at, t) : null;
const handleClick = () => {
setIsOpen(!isOpen);
};
const handleGoToSubscription = () => {
setIsOpen(false);
navigate('/subscription');
};
const handleDeactivateClick = () => {
setDeactivateError(null);
setShowConfirmModal(true);
};
return (
<>
<div className="relative" ref={dropdownRef}>
{/* Badge button */}
<button
onClick={handleClick}
className="group relative flex items-center gap-1 rounded-lg bg-success-500/15 px-2.5 py-1.5 transition-all hover:bg-success-500/25"
title={t('promo.activeDiscount', 'Active discount')}
>
<span className="text-sm font-bold text-success-400">
-{activeDiscount.discount_percent}%
</span>
</button>
{/* Dropdown - mobile: fixed centered, desktop: absolute right */}
{isOpen && (
<>
{/* Mobile backdrop */}
<div
className="fixed inset-0 z-40 bg-black/30 sm:hidden"
onClick={() => setIsOpen(false)}
/>
<div className="fixed left-4 right-4 top-20 z-50 animate-fade-in overflow-hidden rounded-xl border border-dark-700/50 bg-dark-800 shadow-xl sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:mt-2 sm:w-72">
{/* Header */}
<div className="border-b border-dark-700/50 bg-gradient-to-r from-success-500/20 to-accent-500/20 p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-success-500/20 text-success-400">
<SparklesIcon />
</div>
<div>
<div className="font-semibold text-dark-100">{t('promo.discountActive')}</div>
<div className="text-2xl font-bold text-success-400">
-{activeDiscount.discount_percent}%
</div>
</div>
</div>
</div>
{/* Content */}
<div className="space-y-3 p-4">
<p className="text-sm text-dark-300">{t('promo.discountDescription')}</p>
{/* Time remaining */}
{timeLeft && (
<div className="flex items-center gap-2 rounded-lg bg-dark-900/50 px-3 py-2 text-sm text-dark-400">
<ClockIcon />
<span>
{t('promo.expiresIn')}:{' '}
<span className="font-medium text-warning-400">{timeLeft}</span>
</span>
</div>
)}
{/* Deactivation error */}
{deactivateError && (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-400">
{deactivateError}
</div>
)}
{/* CTA Button */}
<button
onClick={handleGoToSubscription}
className="btn-primary w-full py-2.5 text-sm font-medium"
>
{t('promo.useNow')}
</button>
{/* Deactivate Button */}
<button
onClick={handleDeactivateClick}
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-dark-600/50 bg-dark-900/50 py-2 text-sm text-dark-400 transition-colors hover:border-red-500/30 hover:bg-red-500/10 hover:text-red-400"
aria-label={t('promo.deactivate.button')}
>
<XCircleIcon />
<span>{t('promo.deactivate.button')}</span>
</button>
</div>
</div>
</>
)}
</div>
{/* Deactivation Confirmation Modal */}
<DeactivateConfirmModal
isOpen={showConfirmModal}
discountPercent={activeDiscount.discount_percent}
isDeactivating={deactivateMutation.isPending}
onConfirm={handleConfirmDeactivate}
onCancel={handleCloseConfirmModal}
/>
</>
);
}

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { promoApi, PromoOffer } from '../api/promo';
import { ClockIcon, CheckIcon } from './icons';
import { usePlatform } from '@/platform/hooks/usePlatform';

View File

@@ -6,11 +6,11 @@
import { useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useSuccessNotification } from '../store/successNotification';
import { useCurrency } from '../hooks/useCurrency';
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
import { useBackButton, useHaptic } from '@/platform';
import { useTelegramSDK } from '../hooks/useTelegramSDK';
import { useHaptic } from '@/platform';
// Icons
const CheckCircleIcon = () => (
@@ -80,7 +80,7 @@ export default function SuccessNotificationModal() {
const navigate = useNavigate();
const { isOpen, data, hide } = useSuccessNotification();
const { formatAmount, currencySymbol } = useCurrency();
const { safeAreaInset, contentSafeAreaInset, isTelegramWebApp } = useTelegramWebApp();
const { safeAreaInset, contentSafeAreaInset, isTelegramWebApp } = useTelegramSDK();
const haptic = useHaptic();
const safeBottom = isTelegramWebApp
@@ -106,9 +106,6 @@ export default function SuccessNotificationModal() {
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, handleClose]);
// Telegram back button - using platform hook
useBackButton(isOpen ? handleClose : null);
// Haptic feedback on open
useEffect(() => {
if (isOpen) {

View File

@@ -1,579 +0,0 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { COLOR_PRESETS, ColorPreset } from '../data/colorPresets';
import { hexToHsl, hslToHex, isValidHex, HSLColor } from '../utils/colorConversion';
import { ThemeColors, DEFAULT_THEME_COLORS } from '../types/theme';
import { applyThemeColors } from '../hooks/useThemeColors';
interface ThemeBentoPickerProps {
currentColors: ThemeColors;
onColorsChange: (colors: ThemeColors) => void;
onSave: () => void;
isSaving: boolean;
}
const CheckIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
);
const ChevronDownIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
);
const MoonIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<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 SunIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<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 StatusIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
/>
</svg>
);
const PaletteIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<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>
);
function PresetCard({
preset,
isSelected,
onClick,
}: {
preset: ColorPreset;
isSelected: boolean;
onClick: () => void;
}) {
const { i18n } = useTranslation();
const isRu = i18n.language === 'ru';
return (
<button
onClick={onClick}
className={`group relative flex h-full w-full flex-col rounded-2xl p-3 text-left transition-all duration-200 ${
isSelected
? 'z-10 scale-[1.02] border-2 border-accent-500 bg-dark-800/90 shadow-lg shadow-accent-500/20'
: 'border border-dark-700/50 bg-dark-900/60 hover:scale-[1.01] hover:border-dark-600/60 hover:bg-dark-800/70'
}`}
>
<div
className="relative mb-2.5 h-12 w-full shrink-0 overflow-hidden rounded-xl"
style={{ backgroundColor: preset.preview.background }}
>
<div
className="absolute bottom-1.5 left-1.5 h-6 w-6 rounded-lg shadow-md"
style={{ backgroundColor: preset.preview.accent }}
/>
<div
className="absolute bottom-2.5 right-2 h-1 w-10 rounded-full opacity-60"
style={{ backgroundColor: preset.preview.text }}
/>
<div
className="absolute bottom-5 right-2 h-1 w-7 rounded-full opacity-40"
style={{ backgroundColor: preset.preview.text }}
/>
</div>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<h4 className="truncate text-xs font-semibold text-dark-100">
{isRu ? preset.nameRu : preset.name}
</h4>
</div>
{isSelected && (
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-accent-500 text-white">
<CheckIcon />
</div>
)}
</div>
</button>
);
}
function HSLSlider({
label,
value,
onChange,
max,
gradient,
suffix = '',
}: {
label: string;
value: number;
onChange: (value: number) => void;
max: number;
gradient: string;
suffix?: string;
}) {
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-dark-300">{label}</label>
<span className="font-mono text-xs tabular-nums text-dark-500">
{value}
{suffix}
</span>
</div>
<input
type="range"
min="0"
max={max}
value={value}
onChange={(e) => onChange(parseInt(e.target.value))}
className="h-2.5 w-full cursor-pointer appearance-none rounded-full"
style={{ background: gradient }}
/>
</div>
);
}
function CompactColorInput({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (color: string) => void;
}) {
const [localValue, setLocalValue] = useState(value);
const [isEditing, setIsEditing] = useState(false);
useEffect(() => {
setLocalValue(value);
}, [value]);
const handleChange = (newValue: string) => {
let formatted = newValue.toUpperCase();
if (!formatted.startsWith('#')) {
formatted = '#' + formatted;
}
setLocalValue(formatted);
if (isValidHex(formatted)) {
onChange(formatted);
}
};
const handleBlur = () => {
setIsEditing(false);
if (!isValidHex(localValue)) {
setLocalValue(value);
}
};
return (
<div className="group flex items-center gap-2 rounded-xl bg-dark-800/40 p-2 transition-colors hover:bg-dark-800/60">
<button
type="button"
onClick={() => setIsEditing(true)}
className="h-8 w-8 shrink-0 rounded-lg border border-dark-600/50 shadow-inner transition-transform hover:scale-105"
style={{ backgroundColor: value }}
/>
<div className="min-w-0 flex-1">
<label className="mb-0.5 block text-[10px] uppercase leading-none tracking-wide text-dark-500">
{label}
</label>
{isEditing ? (
<input
type="text"
value={localValue}
onChange={(e) => handleChange(e.target.value)}
onBlur={handleBlur}
onKeyDown={(e) => e.key === 'Enter' && handleBlur()}
autoFocus
className="w-full bg-transparent font-mono text-xs text-dark-200 outline-none"
maxLength={7}
/>
) : (
<button
type="button"
onClick={() => setIsEditing(true)}
className="text-left font-mono text-xs text-dark-300 transition-colors hover:text-dark-100"
>
{value.toUpperCase()}
</button>
)}
</div>
</div>
);
}
function CollapsibleSection({
title,
icon,
isOpen,
onToggle,
children,
badge,
}: {
title: string;
icon: React.ReactNode;
isOpen: boolean;
onToggle: () => void;
children: React.ReactNode;
badge?: string;
}) {
return (
<div className="overflow-hidden rounded-2xl border border-dark-700/40 bg-dark-900/50">
<button
onClick={onToggle}
className="flex w-full items-center justify-between px-4 py-3 transition-colors hover:bg-dark-800/30"
>
<div className="flex items-center gap-2.5">
<div className="text-dark-400">{icon}</div>
<span className="text-sm font-medium text-dark-200">{title}</span>
{badge && (
<span className="rounded-md bg-dark-700/50 px-1.5 py-0.5 font-mono text-[10px] text-dark-400">
{badge}
</span>
)}
</div>
<div
className={`text-dark-500 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
>
<ChevronDownIcon />
</div>
</button>
<div
className={`grid transition-all duration-200 ease-out ${
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
}`}
>
<div className="overflow-hidden">
<div className="border-t border-dark-700/30 px-4 pb-4 pt-1">{children}</div>
</div>
</div>
</div>
);
}
export function ThemeBentoPicker({
currentColors,
onColorsChange,
onSave,
isSaving,
}: ThemeBentoPickerProps) {
const { t } = useTranslation();
const [hsl, setHsl] = useState<HSLColor>(() => hexToHsl(currentColors.accent));
const [hexInput, setHexInput] = useState(currentColors.accent);
const [hasChanges, setHasChanges] = useState(false);
const [isPresetsOpen, setIsPresetsOpen] = useState(false);
const [isAccentOpen, setIsAccentOpen] = useState(false);
const [isDarkOpen, setIsDarkOpen] = useState(false);
const [isLightOpen, setIsLightOpen] = useState(false);
const [isStatusOpen, setIsStatusOpen] = useState(false);
const selectedPresetId = useMemo(() => {
const match = COLOR_PRESETS.find(
(p) =>
p.colors.accent.toLowerCase() === currentColors.accent.toLowerCase() &&
p.colors.darkBackground.toLowerCase() === currentColors.darkBackground.toLowerCase() &&
p.colors.lightBackground.toLowerCase() === currentColors.lightBackground.toLowerCase(),
);
return match?.id ?? null;
}, [currentColors.accent, currentColors.darkBackground, currentColors.lightBackground]);
useEffect(() => {
setHsl(hexToHsl(currentColors.accent));
setHexInput(currentColors.accent);
}, [currentColors.accent]);
const updateColor = useCallback(
(key: keyof ThemeColors, value: string) => {
const newColors = { ...currentColors, [key]: value };
onColorsChange(newColors);
applyThemeColors(newColors);
setHasChanges(true);
},
[currentColors, onColorsChange],
);
const updateAccentFromHsl = useCallback(
(newHsl: HSLColor) => {
setHsl(newHsl);
const newHex = hslToHex(newHsl.h, newHsl.s, newHsl.l);
setHexInput(newHex);
updateColor('accent', newHex);
},
[updateColor],
);
const handleHexInputChange = (value: string) => {
setHexInput(value);
if (isValidHex(value)) {
const newHsl = hexToHsl(value);
setHsl(newHsl);
updateColor('accent', value);
}
};
const handlePresetSelect = (preset: ColorPreset) => {
onColorsChange(preset.colors);
applyThemeColors(preset.colors);
setHasChanges(true);
};
const hueGradient = useMemo(() => {
return `linear-gradient(to right,
hsl(0, ${hsl.s}%, ${hsl.l}%),
hsl(60, ${hsl.s}%, ${hsl.l}%),
hsl(120, ${hsl.s}%, ${hsl.l}%),
hsl(180, ${hsl.s}%, ${hsl.l}%),
hsl(240, ${hsl.s}%, ${hsl.l}%),
hsl(300, ${hsl.s}%, ${hsl.l}%),
hsl(360, ${hsl.s}%, ${hsl.l}%)
)`;
}, [hsl.s, hsl.l]);
const saturationGradient = useMemo(() => {
return `linear-gradient(to right,
hsl(${hsl.h}, 0%, ${hsl.l}%),
hsl(${hsl.h}, 100%, ${hsl.l}%)
)`;
}, [hsl.h, hsl.l]);
const lightnessGradient = useMemo(() => {
return `linear-gradient(to right,
hsl(${hsl.h}, ${hsl.s}%, 0%),
hsl(${hsl.h}, ${hsl.s}%, 50%),
hsl(${hsl.h}, ${hsl.s}%, 100%)
)`;
}, [hsl.h, hsl.s]);
return (
<div className="space-y-5">
<CollapsibleSection
title={t('admin.theme.quickPresets', 'Quick Presets')}
icon={<PaletteIcon />}
badge={`${COLOR_PRESETS.length}`}
isOpen={isPresetsOpen}
onToggle={() => setIsPresetsOpen(!isPresetsOpen)}
>
<div className="grid auto-rows-fr grid-cols-2 gap-4 p-1 sm:grid-cols-3">
{COLOR_PRESETS.map((preset, index) => (
<div
key={preset.id}
className="min-h-[100px]"
style={{ '--stagger': index } as React.CSSProperties}
>
<PresetCard
preset={preset}
isSelected={selectedPresetId === preset.id}
onClick={() => handlePresetSelect(preset)}
/>
</div>
))}
</div>
</CollapsibleSection>
<div className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-dark-400">
{t('admin.theme.customizeColors', 'Customize Colors')}
</h3>
<CollapsibleSection
title={t('admin.theme.accentColor', 'Accent Color')}
icon={<PaletteIcon />}
badge={hexInput.toUpperCase()}
isOpen={isAccentOpen}
onToggle={() => setIsAccentOpen(!isAccentOpen)}
>
<div className="space-y-4">
<div
className="relative h-14 w-full overflow-hidden rounded-xl shadow-inner"
style={{
background: `linear-gradient(135deg, ${hexInput} 0%, ${hslToHex(hsl.h, hsl.s, Math.max(20, hsl.l - 20))} 100%)`,
}}
>
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent" />
<div className="absolute bottom-2 right-3 font-mono text-xs text-white/80 drop-shadow">
{hexInput.toUpperCase()}
</div>
</div>
<HSLSlider
label={t('admin.theme.hue', 'Hue')}
value={hsl.h}
onChange={(h) => updateAccentFromHsl({ ...hsl, h })}
max={360}
gradient={hueGradient}
suffix="°"
/>
<HSLSlider
label={t('admin.theme.saturation', 'Saturation')}
value={hsl.s}
onChange={(s) => updateAccentFromHsl({ ...hsl, s })}
max={100}
gradient={saturationGradient}
suffix="%"
/>
<HSLSlider
label={t('admin.theme.lightness', 'Lightness')}
value={hsl.l}
onChange={(l) => updateAccentFromHsl({ ...hsl, l })}
max={100}
gradient={lightnessGradient}
suffix="%"
/>
<div>
<label className="mb-1.5 block text-xs font-medium text-dark-300">
{t('admin.theme.hexCode', 'HEX Code')}
</label>
<input
type="text"
value={hexInput}
onChange={(e) => handleHexInputChange(e.target.value)}
placeholder="#3b82f6"
maxLength={7}
className="input w-full font-mono text-sm uppercase"
/>
</div>
</div>
</CollapsibleSection>
<CollapsibleSection
title={t('theme.darkTheme', 'Dark Theme')}
icon={<MoonIcon />}
isOpen={isDarkOpen}
onToggle={() => setIsDarkOpen(!isDarkOpen)}
>
<div className="grid grid-cols-2 gap-2">
<CompactColorInput
label={t('theme.background', 'Background')}
value={currentColors.darkBackground || DEFAULT_THEME_COLORS.darkBackground}
onChange={(c) => updateColor('darkBackground', c)}
/>
<CompactColorInput
label={t('theme.surface', 'Surface')}
value={currentColors.darkSurface || DEFAULT_THEME_COLORS.darkSurface}
onChange={(c) => updateColor('darkSurface', c)}
/>
<CompactColorInput
label={t('theme.text', 'Text')}
value={currentColors.darkText || DEFAULT_THEME_COLORS.darkText}
onChange={(c) => updateColor('darkText', c)}
/>
<CompactColorInput
label={t('theme.textSecondary', 'Secondary')}
value={currentColors.darkTextSecondary || DEFAULT_THEME_COLORS.darkTextSecondary}
onChange={(c) => updateColor('darkTextSecondary', c)}
/>
</div>
</CollapsibleSection>
<CollapsibleSection
title={t('theme.lightTheme', 'Light Theme')}
icon={<SunIcon />}
isOpen={isLightOpen}
onToggle={() => setIsLightOpen(!isLightOpen)}
>
<div className="grid grid-cols-2 gap-2">
<CompactColorInput
label={t('theme.background', 'Background')}
value={currentColors.lightBackground || DEFAULT_THEME_COLORS.lightBackground}
onChange={(c) => updateColor('lightBackground', c)}
/>
<CompactColorInput
label={t('theme.surface', 'Surface')}
value={currentColors.lightSurface || DEFAULT_THEME_COLORS.lightSurface}
onChange={(c) => updateColor('lightSurface', c)}
/>
<CompactColorInput
label={t('theme.text', 'Text')}
value={currentColors.lightText || DEFAULT_THEME_COLORS.lightText}
onChange={(c) => updateColor('lightText', c)}
/>
<CompactColorInput
label={t('theme.textSecondary', 'Secondary')}
value={currentColors.lightTextSecondary || DEFAULT_THEME_COLORS.lightTextSecondary}
onChange={(c) => updateColor('lightTextSecondary', c)}
/>
</div>
</CollapsibleSection>
<CollapsibleSection
title={t('theme.statusColors', 'Status Colors')}
icon={<StatusIcon />}
isOpen={isStatusOpen}
onToggle={() => setIsStatusOpen(!isStatusOpen)}
>
<div className="grid grid-cols-3 gap-2">
<CompactColorInput
label={t('theme.success', 'Success')}
value={currentColors.success || DEFAULT_THEME_COLORS.success}
onChange={(c) => updateColor('success', c)}
/>
<CompactColorInput
label={t('theme.warning', 'Warning')}
value={currentColors.warning || DEFAULT_THEME_COLORS.warning}
onChange={(c) => updateColor('warning', c)}
/>
<CompactColorInput
label={t('theme.error', 'Error')}
value={currentColors.error || DEFAULT_THEME_COLORS.error}
onChange={(c) => updateColor('error', c)}
/>
</div>
</CollapsibleSection>
</div>
<div className="rounded-2xl border border-dark-700/40 bg-dark-900/50 p-4">
<h4 className="mb-3 text-xs font-medium uppercase tracking-wide text-dark-400">
{t('theme.preview', 'Preview')}
</h4>
<div className="flex flex-wrap gap-2">
<button className="btn-primary text-sm">{t('theme.previewButton', 'Primary')}</button>
<button className="btn-secondary text-sm">
{t('theme.previewSecondary', 'Secondary')}
</button>
<span className="badge-success">{t('theme.success', 'Success')}</span>
<span className="badge-warning">{t('theme.warning', 'Warning')}</span>
<span className="badge-error">{t('theme.error', 'Error')}</span>
</div>
</div>
{hasChanges && (
<div className="flex animate-fade-in justify-end">
<button onClick={onSave} disabled={isSaving} className="btn-primary">
{isSaving ? t('common.saving', 'Saving...') : t('common.save', 'Save')}
</button>
</div>
)}
</div>
);
}

View File

@@ -1,12 +1,12 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { ticketNotificationsApi } from '../api/ticketNotifications';
import { useAuthStore } from '../store/auth';
import { useToast } from './Toast';
import { useWebSocket, WSMessage } from '../hooks/useWebSocket';
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
import { useTelegramSDK } from '../hooks/useTelegramSDK';
import type { TicketNotification } from '../types';
const BellIcon = () => (
@@ -37,7 +37,7 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
const { showToast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp();
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramSDK();
// Calculate dropdown top position (account for fullscreen safe area + TG buttons)
const dropdownTop = isFullscreen

View File

@@ -4,7 +4,7 @@
*/
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import { useWebSocket, WSMessage } from '../hooks/useWebSocket';

View File

@@ -1,4 +1,4 @@
import { Link } from 'react-router-dom';
import { Link } from 'react-router';
import { usePlatform } from '@/platform';
import { BackIcon } from './icons';

View File

@@ -1,14 +0,0 @@
import type { ReactNode } from 'react';
interface AdminLayoutProps {
children: ReactNode;
className?: string;
}
/**
* AdminLayout - wrapper for all admin pages.
* Animations removed to prevent black flash during transitions.
*/
export function AdminLayout({ children, className }: AdminLayoutProps) {
return <div className={className}>{children}</div>;
}

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { brandingApi, setCachedBranding } from '../../api/branding';
import { setCachedAnimationEnabled } from '../AnimatedBackground';
import { setCachedFullscreenEnabled } from '../../hooks/useTelegramWebApp';
import { setCachedFullscreenEnabled } from '../../hooks/useTelegramSDK';
import { UploadIcon, TrashIcon, PencilIcon, CheckIcon, CloseIcon } from './icons';
import { Toggle } from './Toggle';

View File

@@ -1,75 +0,0 @@
import { useTranslation } from 'react-i18next';
import { AdminBackButton, StarIcon, CloseIcon, MENU_SECTIONS } from './index';
interface SettingsSidebarProps {
activeSection: string;
setActiveSection: (section: string) => void;
mobileMenuOpen: boolean;
setMobileMenuOpen: (open: boolean) => void;
favoritesCount: number;
}
export function SettingsSidebar({
activeSection,
setActiveSection,
mobileMenuOpen,
setMobileMenuOpen,
favoritesCount,
}: SettingsSidebarProps) {
const { t } = useTranslation();
return (
<aside
className={`fixed inset-y-0 left-0 z-50 h-screen w-64 flex-shrink-0 transform border-r border-dark-700/50 bg-dark-900 transition-transform duration-200 ease-in-out lg:sticky lg:top-0 ${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'} `}
>
{/* Header */}
<div className="border-b border-dark-700/50 p-4">
<div className="flex items-center gap-3">
<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)}
className="ml-auto rounded-xl bg-dark-800 p-2 transition-colors hover:bg-dark-700 lg:hidden"
>
<CloseIcon />
</button>
</div>
</div>
{/* Menu */}
<nav className="max-h-[calc(100vh-80px)] space-y-1 overflow-y-auto p-2">
{MENU_SECTIONS.map((section, sectionIdx) => (
<div key={section.id}>
{sectionIdx > 0 && <div className="my-3 border-t border-dark-700/50" />}
{section.items.map((item) => {
const isActive = activeSection === item.id;
const hasIcon = item.iconType === 'star';
return (
<button
key={item.id}
onClick={() => {
setActiveSection(item.id);
setMobileMenuOpen(false);
}}
className={`flex w-full items-center gap-3 rounded-xl px-3 py-2.5 transition-all ${
isActive
? 'bg-accent-500/10 text-accent-400'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200'
}`}
>
{hasIcon && <StarIcon filled={isActive && item.id === 'favorites'} />}
<span className="font-medium">{t(`admin.settings.${item.id}`)}</span>
{item.id === 'favorites' && favoritesCount > 0 && (
<span className="ml-auto rounded-full bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
{favoritesCount}
</span>
)}
</button>
);
})}
</div>
))}
</nav>
</aside>
);
}

View File

@@ -59,6 +59,8 @@ export function ThemeTab() {
// Local draft state
const [draftColors, setDraftColors] = useState<ThemeColors>(DEFAULT_THEME_COLORS);
const savedColorsRef = useRef<ThemeColors>(DEFAULT_THEME_COLORS);
const draftColorsRef = useRef(draftColors);
draftColorsRef.current = draftColors;
// Sync server data into draft and saved snapshot when it arrives
useEffect(() => {
@@ -79,14 +81,14 @@ export function ThemeTab() {
};
// Only sync if saved snapshot matches current draft (no unsaved changes)
if (
colorsEqual(savedColorsRef.current, draftColors) ||
colorsEqual(savedColorsRef.current, draftColorsRef.current) ||
colorsEqual(savedColorsRef.current, DEFAULT_THEME_COLORS)
) {
setDraftColors(colors);
}
savedColorsRef.current = colors;
}
}, [serverColors]); // eslint-disable-line react-hooks/exhaustive-deps
}, [serverColors]);
const hasUnsavedChanges = !colorsEqual(draftColors, savedColorsRef.current);

View File

@@ -1,6 +1,5 @@
// Components
export * from './AdminBackButton';
export * from './AdminLayout';
export * from './icons';
export * from './Toggle';
export * from './SettingInput';
@@ -10,7 +9,6 @@ export * from './BrandingTab';
export * from './ThemeTab';
export * from './FavoritesTab';
export * from './SettingsTab';
export * from './SettingsSidebar';
export * from './SettingsMobileTabs';
export * from './SettingsSearch';

View File

@@ -1,56 +0,0 @@
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

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

View File

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

View File

@@ -1,4 +1,4 @@
import { Link, useLocation } from 'react-router-dom';
import { Link, useLocation } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { useState, useEffect } from 'react';

View File

@@ -1,26 +1,19 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { useLocation, useNavigate, Link } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { useLocation, Link } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/store/auth';
import { useBackButton, useHaptic } from '@/platform';
import { useHaptic } from '@/platform';
import { useTelegramSDK } from '@/hooks/useTelegramSDK';
import { useTheme } from '@/hooks/useTheme';
import { useBranding } from '@/hooks/useBranding';
import { useFeatureFlags } from '@/hooks/useFeatureFlags';
import { useScrollRestoration } from '@/hooks/useScrollRestoration';
import { themeColorsApi } from '@/api/themeColors';
import { 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 } from '@/hooks/useTelegramSDK';
import { isLogoPreloaded } from '@/api/branding';
import { cn } from '@/lib/utils';
import { UI } from '@/config/constants';
import WebSocketNotifications from '@/components/WebSocketNotifications';
import SuccessNotificationModal from '@/components/SuccessNotificationModal';
@@ -176,9 +169,6 @@ const MoonIcon = ({ className }: { className?: string }) => (
</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;
}
@@ -186,20 +176,17 @@ interface AppShellProps {
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,
platform,
isMobile,
} = useTelegramSDK();
const { isAdmin, logout } = useAuthStore();
const { isFullscreen, safeAreaInset, contentSafeAreaInset, platform, isMobile } =
useTelegramSDK();
const haptic = useHaptic();
const { toggleTheme, isDark } = useTheme();
// Extracted hooks
const { appName, logoLetter, hasCustomLogo, logoUrl } = useBranding();
const { referralEnabled, wheelEnabled, hasContests, hasPolls } = useFeatureFlags();
useScrollRestoration();
// Theme toggle visibility
const { data: enabledThemes } = useQuery({
queryKey: ['enabled-themes'],
@@ -214,147 +201,6 @@ export function AppShell({ children }: AppShellProps) {
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 && isMobile) {
requestFullscreen();
}
}, [fullscreenSetting, isTelegramWebApp, isFullscreen, requestFullscreen, isMobile]);
// 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', '/wheel'];
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) => {
@@ -405,7 +251,8 @@ export function AppShell({ children }: AppShellProps) {
// Calculate header height based on fullscreen mode (only on mobile Telegram)
// On iOS: contentSafeAreaInset.top includes status bar + dynamic island + Telegram header
// On Android: safeAreaInset.top only includes status bar, need to add Telegram header height (~48px)
const telegramHeaderHeight = platform === 'android' ? 48 : 45;
const telegramHeaderHeight =
platform === 'android' ? UI.TELEGRAM_HEADER_ANDROID_PX : UI.TELEGRAM_HEADER_IOS_PX;
const headerHeight = isMobileFullscreen
? 64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + telegramHeaderHeight
: 64;
@@ -465,7 +312,7 @@ export function AppShell({ children }: AppShellProps) {
<span>{item.label}</span>
</Link>
))}
{referralTerms?.is_enabled && (
{referralEnabled && (
<Link
to="/referral"
onClick={handleNavClick}
@@ -541,10 +388,10 @@ export function AppShell({ children }: AppShellProps) {
safeAreaInset={safeAreaInset}
contentSafeAreaInset={contentSafeAreaInset}
telegramPlatform={platform}
wheelEnabled={wheelConfig?.is_enabled}
referralEnabled={referralTerms?.is_enabled}
hasContests={(contestsCount?.count ?? 0) > 0}
hasPolls={(pollsCount?.count ?? 0) > 0}
wheelEnabled={wheelEnabled}
referralEnabled={referralEnabled}
hasContests={hasContests}
hasPolls={hasPolls}
/>
{/* Desktop spacer */}
@@ -559,8 +406,8 @@ export function AppShell({ children }: AppShellProps) {
{/* Mobile Bottom Navigation */}
<MobileBottomNav
isKeyboardOpen={isKeyboardOpen}
referralEnabled={referralTerms?.is_enabled}
wheelEnabled={wheelConfig?.is_enabled}
referralEnabled={referralEnabled}
wheelEnabled={wheelEnabled}
/>
</div>
);

View File

@@ -154,6 +154,14 @@ export function Aurora() {
const background = isDark ? themeColors.darkBackground : themeColors.lightBackground;
const surface = isDark ? themeColors.darkSurface : themeColors.lightSurface;
// Refs for initial color values (WebGL context shouldn't recreate on color change)
const backgroundRef = useRef(background);
backgroundRef.current = background;
const surfaceRef = useRef(surface);
surfaceRef.current = surface;
const accentRef = useRef(themeColors.accent);
accentRef.current = themeColors.accent;
// Initialize WebGL context once (only depends on isEnabled)
useEffect(() => {
if (!isEnabled || !containerRef.current) return;
@@ -175,7 +183,11 @@ export function Aurora() {
const geometry = new Triangle(gl);
const colorStops = generateColorStops(background, surface, themeColors.accent);
const colorStops = generateColorStops(
backgroundRef.current,
surfaceRef.current,
accentRef.current,
);
const colorStopsArray = colorStops
.map((hex) => {
const c = new Color(hex);
@@ -238,7 +250,7 @@ export function Aurora() {
rendererRef.current = null;
programRef.current = null;
};
}, [isEnabled]); // eslint-disable-line react-hooks/exhaustive-deps
}, [isEnabled]);
// Update color uniforms reactively without recreating WebGL context
useEffect(() => {

View File

@@ -1,4 +1,4 @@
import { Link, useLocation } from 'react-router-dom';
import { Link, useLocation } from 'react-router';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { useQuery } from '@tanstack/react-query';

View File

@@ -1,4 +1,4 @@
import { Link, useLocation } from 'react-router-dom';
import { Link, useLocation } from 'react-router';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';

View File

@@ -1,57 +0,0 @@
import { motion } from 'framer-motion';
/**
* Animated moving gradient background.
* Uses CSS variables for colors to support theme switching.
* Lightweight - pure CSS gradients with Framer Motion animation.
*/
export function MovingGradient() {
return (
<div className="fixed inset-0 -z-10 overflow-hidden">
{/* Base background */}
<div className="absolute inset-0 bg-dark-950" />
{/* Animated gradient layer */}
<motion.div
className="absolute inset-0 opacity-60"
style={{
background:
'radial-gradient(ellipse 80% 50% at 20% 40%, rgba(var(--color-accent-500), 0.12) 0%, transparent 50%), radial-gradient(ellipse 60% 40% at 80% 60%, rgba(var(--color-accent-500), 0.08) 0%, transparent 50%)',
}}
animate={{
backgroundPosition: ['0% 0%', '100% 100%', '0% 0%'],
}}
transition={{
duration: 20,
repeat: Infinity,
ease: 'linear',
}}
/>
{/* Second animated layer - moves in opposite direction */}
<motion.div
className="absolute inset-0 opacity-40"
style={{
background:
'radial-gradient(ellipse 70% 60% at 70% 30%, rgba(var(--color-accent-500), 0.1) 0%, transparent 50%), radial-gradient(ellipse 50% 50% at 30% 70%, rgba(var(--color-accent-500), 0.06) 0%, transparent 50%)',
}}
animate={{
backgroundPosition: ['100% 100%', '0% 0%', '100% 100%'],
}}
transition={{
duration: 25,
repeat: Infinity,
ease: 'linear',
}}
/>
{/* Subtle noise texture overlay */}
<div
className="absolute inset-0 opacity-[0.02]"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
}}
/>
</div>
);
}

View File

@@ -7,13 +7,6 @@ export const springTransition: Transition = {
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;
@@ -42,27 +35,6 @@ export const slideUpTransition: Transition = {
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 },
@@ -92,16 +64,6 @@ export const staggerContainer: Variants = {
},
};
// 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 = {

View File

@@ -1,5 +1,5 @@
import { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { AnimatePresence, motion } from 'framer-motion';
import * as DialogPrimitive from '@radix-ui/react-dialog';

View File

@@ -10,6 +10,12 @@ import {
import { cn } from '@/lib/utils';
import { backdrop, backdropTransition, scale, scaleTransition } from '../../motion/transitions';
export {
Trigger as DialogTrigger,
Portal as DialogPortal,
Close as DialogClose,
} from '@radix-ui/react-dialog';
// Close icon
const CloseIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
@@ -41,15 +47,6 @@ export const Dialog = ({ children, open, onOpenChange, ...props }: DialogProps)
);
};
// 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>;
@@ -86,7 +83,7 @@ export const DialogContent = forwardRef<HTMLDivElement, DialogContentProps>(
const { open } = useContext(DialogContext);
return (
<DialogPortal forceMount>
<DialogPrimitive.Portal forceMount>
<AnimatePresence mode="wait">
{open && (
<>
@@ -131,7 +128,7 @@ export const DialogContent = forwardRef<HTMLDivElement, DialogContentProps>(
</>
)}
</AnimatePresence>
</DialogPortal>
</DialogPrimitive.Portal>
);
},
);

View File

@@ -5,6 +5,15 @@ import { cn } from '@/lib/utils';
import { usePlatform } from '@/platform';
import { dropdown, dropdownTransition } from '../../motion/transitions';
export {
Root as DropdownMenu,
Trigger as DropdownMenuTrigger,
Group as DropdownMenuGroup,
Portal as DropdownMenuPortal,
Sub as DropdownMenuSub,
RadioGroup as DropdownMenuRadioGroup,
} from '@radix-ui/react-dropdown-menu';
// Icons
const CheckIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
@@ -36,24 +45,6 @@ const DotIcon = () => (
</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

View File

@@ -4,6 +4,13 @@ import { forwardRef, type ComponentPropsWithoutRef } from 'react';
import { cn } from '@/lib/utils';
import { dropdown, dropdownTransition } from '../../motion/transitions';
export {
Root as Popover,
Trigger as PopoverTrigger,
Anchor as PopoverAnchor,
Close as PopoverClose,
} from '@radix-ui/react-popover';
// Close icon
const CloseIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
@@ -17,18 +24,6 @@ const CloseIcon = () => (
</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

View File

@@ -5,6 +5,8 @@ import { cn } from '@/lib/utils';
import { usePlatform } from '@/platform';
import { dropdown, dropdownTransition } from '../../motion/transitions';
export { Root as Select, Group as SelectGroup } from '@radix-ui/react-select';
// Icons
const ChevronDownIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-dark-400">
@@ -30,9 +32,6 @@ const CheckIcon = () => (
</svg>
);
// Root
export const Select = SelectPrimitive.Root;
// Trigger
export interface SelectTriggerProps extends ComponentPropsWithoutRef<
typeof SelectPrimitive.Trigger
@@ -149,9 +148,6 @@ export const SelectItem = forwardRef<HTMLDivElement, SelectItemProps>(
SelectItem.displayName = 'SelectItem';
// Group
export const SelectGroup = SelectPrimitive.Group;
// Label
export type SelectLabelProps = ComponentPropsWithoutRef<typeof SelectPrimitive.Label>;

View File

@@ -10,7 +10,6 @@ import {
} from 'react';
import { cn } from '@/lib/utils';
import { usePlatform } from '@/platform';
import { useBackButton } from '@/platform';
import {
backdrop,
backdropTransition,
@@ -18,6 +17,12 @@ import {
sheetTransition,
} from '../../motion/transitions';
export {
Trigger as SheetTrigger,
Portal as SheetPortal,
Close as SheetClose,
} from '@radix-ui/react-dialog';
// Close icon
const CloseIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
@@ -79,15 +84,6 @@ export const Sheet = ({ children, open, onOpenChange, onClose, ...props }: Sheet
);
};
// 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>;
@@ -138,9 +134,6 @@ export const SheetContent = forwardRef<HTMLDivElement, SheetContentProps>(
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;
@@ -157,7 +150,7 @@ export const SheetContent = forwardRef<HTMLDivElement, SheetContentProps>(
);
return (
<SheetPortal forceMount>
<DialogPrimitive.Portal forceMount>
<AnimatePresence mode="wait">
{open && (
<>
@@ -221,7 +214,7 @@ export const SheetContent = forwardRef<HTMLDivElement, SheetContentProps>(
</>
)}
</AnimatePresence>
</SheetPortal>
</DialogPrimitive.Portal>
);
},
);

View File

@@ -4,14 +4,11 @@ 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;
export {
Provider as TooltipProvider,
Root as Tooltip,
Trigger as TooltipTrigger,
} from '@radix-ui/react-tooltip';
// Content
export type TooltipContentProps = ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>;
@@ -66,10 +63,10 @@ export const SimpleTooltip = ({
delayDuration = 200,
className,
}: SimpleTooltipProps) => (
<Tooltip delayDuration={delayDuration}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipPrimitive.Root delayDuration={delayDuration}>
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
<TooltipContent side={side} align={align} className={className}>
{content}
</TooltipContent>
</Tooltip>
</TooltipPrimitive.Root>
);

View File

@@ -1,4 +1,4 @@
import { Link } from 'react-router-dom';
import { Link } from 'react-router';
import { forwardRef, useCallback } from 'react';
import { useHaptic } from '@/platform';

View File

@@ -1,6 +1,5 @@
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 {
@@ -105,9 +104,6 @@ export function Sheet({
const haptic = useHaptic();
// BackButton integration
useBackButton(isOpen ? onClose : null);
// Calculate current height based on snap point
const currentHeight = `${snapPoints[currentSnapIndex] * 100}vh`;

View File

@@ -131,69 +131,3 @@ export function Skeleton({
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;