mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
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:
34
src/App.tsx
34
src/App.tsx
@@ -7,7 +7,6 @@ import PageLoader from './components/common/PageLoader';
|
||||
import { MaintenanceScreen, ChannelSubscriptionScreen } from './components/blocking';
|
||||
import { saveReturnUrl } from './utils/token';
|
||||
import { useAnalyticsCounters } from './hooks/useAnalyticsCounters';
|
||||
|
||||
// Auth pages - load immediately (small)
|
||||
import Login from './pages/Login';
|
||||
import TelegramCallback from './pages/TelegramCallback';
|
||||
@@ -47,6 +46,9 @@ const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods'));
|
||||
const AdminPromoOffers = lazy(() => import('./pages/AdminPromoOffers'));
|
||||
const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave'));
|
||||
const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates'));
|
||||
const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail'));
|
||||
const AdminBroadcastDetail = lazy(() => import('./pages/AdminBroadcastDetail'));
|
||||
const AdminEmailTemplatePreview = lazy(() => import('./pages/AdminEmailTemplatePreview'));
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuthStore();
|
||||
@@ -405,6 +407,36 @@ function App() {
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/users/:id"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminUserDetail />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/broadcasts/:id"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminBroadcastDetail />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/email-templates/preview/:type/:lang"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminEmailTemplatePreview />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Catch all */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
33
src/components/admin/AdminBackButton.tsx
Normal file
33
src/components/admin/AdminBackButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
src/components/admin/AdminLayout.tsx
Normal file
61
src/components/admin/AdminLayout.tsx
Normal 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 },
|
||||
};
|
||||
78
src/components/admin/SettingsMobileTabs.tsx
Normal file
78
src/components/admin/SettingsMobileTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
175
src/components/data-display/Card/Card.tsx
Normal file
175
src/components/data-display/Card/Card.tsx
Normal 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';
|
||||
14
src/components/data-display/Card/index.ts
Normal file
14
src/components/data-display/Card/index.ts
Normal 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';
|
||||
56
src/components/data-display/EmptyState/EmptyState.tsx
Normal file
56
src/components/data-display/EmptyState/EmptyState.tsx
Normal 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';
|
||||
1
src/components/data-display/EmptyState/index.ts
Normal file
1
src/components/data-display/EmptyState/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { EmptyState, type EmptyStateProps } from './EmptyState';
|
||||
83
src/components/data-display/StatCard/StatCard.tsx
Normal file
83
src/components/data-display/StatCard/StatCard.tsx
Normal 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';
|
||||
1
src/components/data-display/StatCard/index.ts
Normal file
1
src/components/data-display/StatCard/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { StatCard, type StatCardProps } from './StatCard';
|
||||
3
src/components/data-display/index.ts
Normal file
3
src/components/data-display/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './Card';
|
||||
export * from './StatCard';
|
||||
export * from './EmptyState';
|
||||
427
src/components/layout/AppShell/AppHeader.tsx
Normal file
427
src/components/layout/AppShell/AppHeader.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
517
src/components/layout/AppShell/AppShell.tsx
Normal file
517
src/components/layout/AppShell/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
210
src/components/layout/AppShell/DesktopSidebar.tsx
Normal file
210
src/components/layout/AppShell/DesktopSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
src/components/layout/AppShell/MobileBottomNav.tsx
Normal file
78
src/components/layout/AppShell/MobileBottomNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
330
src/components/layout/AppShell/icons.tsx
Normal file
330
src/components/layout/AppShell/icons.tsx
Normal 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>
|
||||
);
|
||||
4
src/components/layout/AppShell/index.ts
Normal file
4
src/components/layout/AppShell/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { AppShell } from './AppShell';
|
||||
export { DesktopSidebar } from './DesktopSidebar';
|
||||
export { MobileBottomNav } from './MobileBottomNav';
|
||||
export { AppHeader } from './AppHeader';
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
33
src/components/motion/FadeIn.tsx
Normal file
33
src/components/motion/FadeIn.tsx
Normal 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';
|
||||
33
src/components/motion/SlideUp.tsx
Normal file
33
src/components/motion/SlideUp.tsx
Normal 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';
|
||||
4
src/components/motion/index.ts
Normal file
4
src/components/motion/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { AnimatePresence } from 'framer-motion';
|
||||
export { FadeIn } from './FadeIn';
|
||||
export { SlideUp } from './SlideUp';
|
||||
export * from './transitions';
|
||||
180
src/components/motion/transitions.ts
Normal file
180
src/components/motion/transitions.ts
Normal 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,
|
||||
};
|
||||
218
src/components/navigation/CommandPalette/CommandPalette.tsx
Normal file
218
src/components/navigation/CommandPalette/CommandPalette.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/components/navigation/CommandPalette/index.ts
Normal file
1
src/components/navigation/CommandPalette/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CommandPalette } from './CommandPalette';
|
||||
104
src/components/primitives/Button/Button.tsx
Normal file
104
src/components/primitives/Button/Button.tsx
Normal 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 };
|
||||
64
src/components/primitives/Button/Button.variants.ts
Normal file
64
src/components/primitives/Button/Button.variants.ts
Normal 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>;
|
||||
1
src/components/primitives/Button/index.ts
Normal file
1
src/components/primitives/Button/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Button, type ButtonProps, buttonVariants, type ButtonVariants } from './Button';
|
||||
172
src/components/primitives/Command/Command.tsx
Normal file
172
src/components/primitives/Command/Command.tsx
Normal 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';
|
||||
20
src/components/primitives/Command/index.ts
Normal file
20
src/components/primitives/Command/index.ts
Normal 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';
|
||||
190
src/components/primitives/Dialog/Dialog.tsx
Normal file
190
src/components/primitives/Dialog/Dialog.tsx
Normal 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';
|
||||
19
src/components/primitives/Dialog/index.ts
Normal file
19
src/components/primitives/Dialog/index.ts
Normal 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';
|
||||
294
src/components/primitives/DropdownMenu/DropdownMenu.tsx
Normal file
294
src/components/primitives/DropdownMenu/DropdownMenu.tsx
Normal 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';
|
||||
26
src/components/primitives/DropdownMenu/index.ts
Normal file
26
src/components/primitives/DropdownMenu/index.ts
Normal 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';
|
||||
96
src/components/primitives/Popover/Popover.tsx
Normal file
96
src/components/primitives/Popover/Popover.tsx
Normal 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';
|
||||
10
src/components/primitives/Popover/index.ts
Normal file
10
src/components/primitives/Popover/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverAnchor,
|
||||
PopoverClose,
|
||||
PopoverContent,
|
||||
PopoverArrow,
|
||||
type PopoverContentProps,
|
||||
type PopoverArrowProps,
|
||||
} from './Popover';
|
||||
183
src/components/primitives/Select/Select.tsx
Normal file
183
src/components/primitives/Select/Select.tsx
Normal 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';
|
||||
14
src/components/primitives/Select/index.ts
Normal file
14
src/components/primitives/Select/index.ts
Normal 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';
|
||||
284
src/components/primitives/Sheet/Sheet.tsx
Normal file
284
src/components/primitives/Sheet/Sheet.tsx
Normal 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';
|
||||
19
src/components/primitives/Sheet/index.ts
Normal file
19
src/components/primitives/Sheet/index.ts
Normal 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';
|
||||
73
src/components/primitives/Switch/Switch.tsx
Normal file
73
src/components/primitives/Switch/Switch.tsx
Normal 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';
|
||||
1
src/components/primitives/Switch/index.ts
Normal file
1
src/components/primitives/Switch/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Switch, type SwitchProps } from './Switch';
|
||||
75
src/components/primitives/Tooltip/Tooltip.tsx
Normal file
75
src/components/primitives/Tooltip/Tooltip.tsx
Normal 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>
|
||||
);
|
||||
9
src/components/primitives/Tooltip/index.ts
Normal file
9
src/components/primitives/Tooltip/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
TooltipProvider,
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
SimpleTooltip,
|
||||
type TooltipContentProps,
|
||||
type SimpleTooltipProps,
|
||||
} from './Tooltip';
|
||||
10
src/components/primitives/index.ts
Normal file
10
src/components/primitives/index.ts
Normal 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';
|
||||
@@ -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
396
src/components/ui/Sheet.tsx
Normal 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; }
|
||||
199
src/components/ui/Skeleton.tsx
Normal file
199
src/components/ui/Skeleton.tsx
Normal 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;
|
||||
@@ -1,162 +0,0 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
|
||||
interface UsePullToRefreshOptions {
|
||||
onRefresh?: () => void | Promise<void>;
|
||||
threshold?: number; // How far to pull before triggering refresh (px)
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function usePullToRefresh({
|
||||
onRefresh,
|
||||
threshold = 80,
|
||||
disabled = false,
|
||||
}: UsePullToRefreshOptions = {}) {
|
||||
const [isPulling, setIsPulling] = useState(false);
|
||||
const [pullDistance, setPullDistance] = useState(0);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const startY = useRef(0);
|
||||
const currentY = useRef(0);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (onRefresh) {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await onRefresh();
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
} else {
|
||||
// Default: reload the page
|
||||
window.location.reload();
|
||||
}
|
||||
}, [onRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) return;
|
||||
|
||||
// Check if a modal/overlay is currently open
|
||||
const isModalOpen = (): boolean => {
|
||||
// Check if body scroll is locked (common pattern for modals)
|
||||
if (document.body.style.overflow === 'hidden') return true;
|
||||
// Check for high z-index fixed elements (modals typically use z-index > 50)
|
||||
const fixedElements = document.querySelectorAll(
|
||||
'[style*="position: fixed"], [style*="position:fixed"]',
|
||||
);
|
||||
for (const el of fixedElements) {
|
||||
const style = window.getComputedStyle(el);
|
||||
const zIndex = parseInt(style.zIndex, 10);
|
||||
if (zIndex >= 50 && el.clientHeight > window.innerHeight * 0.5) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Check if element or any parent is scrollable and not at top
|
||||
const isInsideScrollableContainer = (element: HTMLElement | null): boolean => {
|
||||
while (element && element !== document.body) {
|
||||
const style = window.getComputedStyle(element);
|
||||
const overflowY = style.overflowY;
|
||||
const isScrollable = overflowY === 'auto' || overflowY === 'scroll';
|
||||
|
||||
if (isScrollable && element.scrollHeight > element.clientHeight) {
|
||||
// Element is scrollable - check if it's at the top
|
||||
if (element.scrollTop > 0) {
|
||||
return true; // Not at top, don't trigger pull-to-refresh
|
||||
}
|
||||
}
|
||||
element = element.parentElement;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
// Don't trigger if a modal is open
|
||||
if (isModalOpen()) return;
|
||||
|
||||
// Only trigger if at top of page
|
||||
if (window.scrollY > 5) return;
|
||||
|
||||
// Don't trigger if touch started inside a scrollable container that's not at top
|
||||
const target = e.target as HTMLElement;
|
||||
if (isInsideScrollableContainer(target)) return;
|
||||
|
||||
startY.current = e.touches[0].clientY;
|
||||
currentY.current = e.touches[0].clientY;
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (startY.current === 0) return;
|
||||
|
||||
// Cancel if modal opened during gesture
|
||||
if (isModalOpen()) {
|
||||
startY.current = 0;
|
||||
setPullDistance(0);
|
||||
setIsPulling(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.scrollY > 5) {
|
||||
// User scrolled down, reset
|
||||
startY.current = 0;
|
||||
setPullDistance(0);
|
||||
setIsPulling(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Also check during move - user might start at top then scroll inside container
|
||||
const target = e.target as HTMLElement;
|
||||
if (isInsideScrollableContainer(target)) {
|
||||
startY.current = 0;
|
||||
setPullDistance(0);
|
||||
setIsPulling(false);
|
||||
return;
|
||||
}
|
||||
|
||||
currentY.current = e.touches[0].clientY;
|
||||
const diff = currentY.current - startY.current;
|
||||
|
||||
// Only track downward pulls
|
||||
if (diff > 0) {
|
||||
// Apply resistance - pull distance is less than actual finger movement
|
||||
const resistance = 0.4;
|
||||
const distance = Math.min(diff * resistance, threshold * 1.5);
|
||||
setPullDistance(distance);
|
||||
setIsPulling(true);
|
||||
|
||||
// Prevent default scroll if we're pulling
|
||||
if (distance > 10) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (pullDistance >= threshold && !isRefreshing) {
|
||||
handleRefresh();
|
||||
}
|
||||
|
||||
startY.current = 0;
|
||||
setPullDistance(0);
|
||||
setIsPulling(false);
|
||||
};
|
||||
|
||||
document.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', handleTouchEnd, { passive: true });
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('touchstart', handleTouchStart);
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
};
|
||||
}, [disabled, threshold, pullDistance, isRefreshing, handleRefresh]);
|
||||
|
||||
return {
|
||||
isPulling,
|
||||
pullDistance,
|
||||
isRefreshing,
|
||||
progress: Math.min(pullDistance / threshold, 1),
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,19 @@ import { useEffect, useState, useCallback } from 'react';
|
||||
|
||||
const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled';
|
||||
|
||||
/**
|
||||
* Check if running on mobile Telegram client (iOS/Android)
|
||||
* Fullscreen mode should only be applied on mobile platforms
|
||||
*/
|
||||
export function isTelegramMobile(): boolean {
|
||||
const webApp = typeof window !== 'undefined' ? window.Telegram?.WebApp : undefined;
|
||||
if (!webApp?.platform) return false;
|
||||
|
||||
// Only iOS and Android are mobile platforms
|
||||
// tdesktop, macos, web, unknown - all are desktop/non-mobile
|
||||
return webApp.platform === 'ios' || webApp.platform === 'android';
|
||||
}
|
||||
|
||||
// Get cached fullscreen setting
|
||||
export const getCachedFullscreenEnabled = (): boolean => {
|
||||
try {
|
||||
@@ -27,18 +40,29 @@ export const setCachedFullscreenEnabled = (enabled: boolean) => {
|
||||
export function useTelegramWebApp() {
|
||||
// Initialize synchronously to avoid flash/flicker on first render
|
||||
const webApp = typeof window !== 'undefined' ? window.Telegram?.WebApp : undefined;
|
||||
const inTelegram = isInTelegramWebApp();
|
||||
|
||||
const [isFullscreen, setIsFullscreen] = useState(() => webApp?.isFullscreen || false);
|
||||
const [isTelegramWebApp, setIsTelegramWebApp] = useState(() => !!webApp);
|
||||
const [isFullscreen, setIsFullscreen] = useState(
|
||||
() => (inTelegram && webApp?.isFullscreen) || false,
|
||||
);
|
||||
const [isTelegramWebApp, setIsTelegramWebApp] = useState(() => inTelegram);
|
||||
const [safeAreaInset, setSafeAreaInset] = useState(
|
||||
() => webApp?.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
() => (inTelegram && webApp?.safeAreaInset) || { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
);
|
||||
const [contentSafeAreaInset, setContentSafeAreaInset] = useState(
|
||||
() => webApp?.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
() =>
|
||||
(inTelegram && webApp?.contentSafeAreaInset) || {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!webApp) {
|
||||
// Only run Telegram-specific code if we're actually in Telegram
|
||||
const isActuallyInTelegram = isInTelegramWebApp();
|
||||
if (!webApp || !isActuallyInTelegram) {
|
||||
setIsTelegramWebApp(false);
|
||||
return;
|
||||
}
|
||||
@@ -148,33 +172,47 @@ export function useTelegramWebApp() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're actually running inside Telegram Mini App
|
||||
* (not just the script loaded on a regular webpage)
|
||||
*/
|
||||
export function isInTelegramWebApp(): boolean {
|
||||
const webApp = window.Telegram?.WebApp;
|
||||
// Check if initData exists - it's empty when not in Telegram
|
||||
return Boolean(webApp?.initData && webApp.initData.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Telegram WebApp on app start
|
||||
* Call this in main.tsx or App.tsx
|
||||
*/
|
||||
export function initTelegramWebApp() {
|
||||
const webApp = window.Telegram?.WebApp;
|
||||
if (webApp) {
|
||||
webApp.ready();
|
||||
webApp.expand();
|
||||
// Only initialize if we're actually in Telegram
|
||||
if (!webApp || !isInTelegramWebApp()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable vertical swipes to prevent accidental closing (requires Bot API 7.7+)
|
||||
try {
|
||||
if (webApp.disableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
|
||||
webApp.disableVerticalSwipes();
|
||||
}
|
||||
} catch {
|
||||
// Swipe control not supported in this version
|
||||
webApp.ready();
|
||||
webApp.expand();
|
||||
|
||||
// Disable vertical swipes to prevent accidental closing (requires Bot API 7.7+)
|
||||
try {
|
||||
if (webApp.disableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
|
||||
webApp.disableVerticalSwipes();
|
||||
}
|
||||
} catch {
|
||||
// Swipe control not supported in this version
|
||||
}
|
||||
|
||||
// Auto-enter fullscreen if enabled in settings (use cached value for instant response)
|
||||
const fullscreenEnabled = getCachedFullscreenEnabled();
|
||||
if (fullscreenEnabled && webApp.requestFullscreen && !webApp.isFullscreen) {
|
||||
try {
|
||||
webApp.requestFullscreen();
|
||||
} catch (e) {
|
||||
console.warn('Auto-fullscreen failed:', e);
|
||||
}
|
||||
// Auto-enter fullscreen if enabled in settings (use cached value for instant response)
|
||||
// Only apply fullscreen on mobile Telegram (iOS/Android) - desktop doesn't need it
|
||||
const fullscreenEnabled = getCachedFullscreenEnabled();
|
||||
if (fullscreenEnabled && isTelegramMobile() && webApp.requestFullscreen && !webApp.isFullscreen) {
|
||||
try {
|
||||
webApp.requestFullscreen();
|
||||
} catch (e) {
|
||||
console.warn('Auto-fullscreen failed:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
187
src/hooks/useUserThemePreferences.ts
Normal file
187
src/hooks/useUserThemePreferences.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { usePlatform } from '@/platform';
|
||||
import {
|
||||
type UserThemePreferences,
|
||||
type BorderRadiusPreset,
|
||||
type ThemeMode,
|
||||
DEFAULT_USER_PREFERENCES,
|
||||
BORDER_RADIUS_VALUES,
|
||||
} from '../types/theme';
|
||||
|
||||
const STORAGE_KEY = 'user_theme_preferences';
|
||||
|
||||
/**
|
||||
* Parse preferences from storage string
|
||||
*/
|
||||
function parsePreferences(value: string | null): UserThemePreferences {
|
||||
if (!value) return DEFAULT_USER_PREFERENCES;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return {
|
||||
theme: parsed.theme ?? DEFAULT_USER_PREFERENCES.theme,
|
||||
borderRadius: parsed.borderRadius ?? DEFAULT_USER_PREFERENCES.borderRadius,
|
||||
animationsEnabled: parsed.animationsEnabled ?? DEFAULT_USER_PREFERENCES.animationsEnabled,
|
||||
};
|
||||
} catch {
|
||||
return DEFAULT_USER_PREFERENCES;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply CSS variables to document
|
||||
*/
|
||||
function applyPreferencesToDOM(preferences: UserThemePreferences): void {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Apply border radius
|
||||
root.style.setProperty('--bento-radius', BORDER_RADIUS_VALUES[preferences.borderRadius]);
|
||||
|
||||
// Apply animations toggle
|
||||
if (preferences.animationsEnabled) {
|
||||
root.classList.remove('reduce-motion');
|
||||
} else {
|
||||
root.classList.add('reduce-motion');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage user theme preferences
|
||||
* Stores in localStorage and optionally syncs with Telegram CloudStorage
|
||||
*/
|
||||
export function useUserThemePreferences() {
|
||||
const { cloudStorage, capabilities } = usePlatform();
|
||||
const [preferences, setPreferencesState] =
|
||||
useState<UserThemePreferences>(DEFAULT_USER_PREFERENCES);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Load preferences on mount
|
||||
useEffect(() => {
|
||||
async function loadPreferences() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Try Telegram CloudStorage first if available
|
||||
if (capabilities.hasCloudStorage && cloudStorage) {
|
||||
const cloudValue = await cloudStorage.getItem(STORAGE_KEY);
|
||||
if (cloudValue) {
|
||||
const prefs = parsePreferences(cloudValue);
|
||||
setPreferencesState(prefs);
|
||||
applyPreferencesToDOM(prefs);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to localStorage
|
||||
const localValue = localStorage.getItem(STORAGE_KEY);
|
||||
const prefs = parsePreferences(localValue);
|
||||
setPreferencesState(prefs);
|
||||
applyPreferencesToDOM(prefs);
|
||||
} catch (error) {
|
||||
console.warn('Failed to load theme preferences:', error);
|
||||
applyPreferencesToDOM(DEFAULT_USER_PREFERENCES);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
loadPreferences();
|
||||
}, [cloudStorage, capabilities.hasCloudStorage]);
|
||||
|
||||
// Save preferences
|
||||
const savePreferences = useCallback(
|
||||
async (newPreferences: UserThemePreferences) => {
|
||||
const value = JSON.stringify(newPreferences);
|
||||
|
||||
// Save to localStorage
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, value);
|
||||
} catch (error) {
|
||||
console.warn('Failed to save to localStorage:', error);
|
||||
}
|
||||
|
||||
// Sync to Telegram CloudStorage if available
|
||||
if (capabilities.hasCloudStorage && cloudStorage) {
|
||||
try {
|
||||
await cloudStorage.setItem(STORAGE_KEY, value);
|
||||
} catch (error) {
|
||||
console.warn('Failed to sync to CloudStorage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply to DOM
|
||||
applyPreferencesToDOM(newPreferences);
|
||||
setPreferencesState(newPreferences);
|
||||
},
|
||||
[cloudStorage, capabilities.hasCloudStorage],
|
||||
);
|
||||
|
||||
// Update individual preference
|
||||
const updatePreference = useCallback(
|
||||
<K extends keyof UserThemePreferences>(key: K, value: UserThemePreferences[K]) => {
|
||||
const newPreferences = { ...preferences, [key]: value };
|
||||
savePreferences(newPreferences);
|
||||
},
|
||||
[preferences, savePreferences],
|
||||
);
|
||||
|
||||
// Convenience setters
|
||||
const setTheme = useCallback(
|
||||
(theme: ThemeMode) => updatePreference('theme', theme),
|
||||
[updatePreference],
|
||||
);
|
||||
|
||||
const setBorderRadius = useCallback(
|
||||
(borderRadius: BorderRadiusPreset) => updatePreference('borderRadius', borderRadius),
|
||||
[updatePreference],
|
||||
);
|
||||
|
||||
const setAnimationsEnabled = useCallback(
|
||||
(enabled: boolean) => updatePreference('animationsEnabled', enabled),
|
||||
[updatePreference],
|
||||
);
|
||||
|
||||
// Reset to defaults
|
||||
const resetPreferences = useCallback(() => {
|
||||
savePreferences(DEFAULT_USER_PREFERENCES);
|
||||
}, [savePreferences]);
|
||||
|
||||
return {
|
||||
preferences,
|
||||
isLoading,
|
||||
setTheme,
|
||||
setBorderRadius,
|
||||
setAnimationsEnabled,
|
||||
updatePreference,
|
||||
resetPreferences,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get resolved theme (respects system preference when set to 'system')
|
||||
*/
|
||||
export function useResolvedTheme() {
|
||||
const { preferences } = useUserThemePreferences();
|
||||
const [systemTheme, setSystemTheme] = useState<'dark' | 'light'>('dark');
|
||||
|
||||
useEffect(() => {
|
||||
// Get initial system preference
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
setSystemTheme(mediaQuery.matches ? 'dark' : 'light');
|
||||
|
||||
// Listen for changes
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
setSystemTheme(e.matches ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handler);
|
||||
return () => mediaQuery.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
if (preferences.theme === 'system') {
|
||||
return systemTheme;
|
||||
}
|
||||
|
||||
return preferences.theme;
|
||||
}
|
||||
10
src/lib/utils.ts
Normal file
10
src/lib/utils.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/**
|
||||
* Merge class names with Tailwind CSS classes
|
||||
* Uses clsx for conditional classes and tailwind-merge to resolve conflicts
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -28,7 +28,8 @@
|
||||
"perGb": "/GB"
|
||||
},
|
||||
"retry": "Retry",
|
||||
"saving": "Saving..."
|
||||
"saving": "Saving...",
|
||||
"understand": "Got it"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -761,7 +762,9 @@
|
||||
"resetted": "Template reset to default",
|
||||
"testSent": "Test email sent",
|
||||
"variables": "Available Variables",
|
||||
"clickToCopy": "Click to copy"
|
||||
"clickToCopy": "Click to copy",
|
||||
"previewDesktopOnly": "Preview is only available in the desktop web version",
|
||||
"previewNotAvailable": "Preview not available"
|
||||
},
|
||||
"payments": {
|
||||
"title": "Payment verification",
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
"add": "افزودن",
|
||||
"refresh": "بازخوانی",
|
||||
"retry": "تلاش مجدد",
|
||||
"saving": "در حال ذخیره..."
|
||||
"saving": "در حال ذخیره...",
|
||||
"understand": "متوجه شدم"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "داشبورد",
|
||||
@@ -667,7 +668,9 @@
|
||||
"resetted": "قالب به پیشفرض بازگردانده شد",
|
||||
"testSent": "ایمیل آزمایشی ارسال شد",
|
||||
"variables": "متغیرهای موجود",
|
||||
"clickToCopy": "برای کپی کلیک کنید"
|
||||
"clickToCopy": "برای کپی کلیک کنید",
|
||||
"previewDesktopOnly": "پیشنمایش فقط در نسخه وب دسکتاپ در دسترس است",
|
||||
"previewNotAvailable": "پیشنمایش در دسترس نیست"
|
||||
},
|
||||
"wheel": {
|
||||
"title": "تنظیمات چرخ شانس",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"copy": "Копировать",
|
||||
"retry": "Повторить",
|
||||
"saving": "Сохранение...",
|
||||
"understand": "Понятно",
|
||||
"units": {
|
||||
"gb": "ГБ",
|
||||
"perGb": "/ГБ"
|
||||
@@ -777,7 +778,9 @@
|
||||
"resetted": "Шаблон сброшен к значению по умолчанию",
|
||||
"testSent": "Тестовое письмо отправлено",
|
||||
"variables": "Доступные переменные",
|
||||
"clickToCopy": "Нажмите для копирования"
|
||||
"clickToCopy": "Нажмите для копирования",
|
||||
"previewDesktopOnly": "Предпросмотр доступен только в веб-версии на десктопе",
|
||||
"previewNotAvailable": "Предпросмотр недоступен"
|
||||
},
|
||||
"payments": {
|
||||
"title": "Проверка платежей",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"refresh": "刷新",
|
||||
"retry": "重试",
|
||||
"saving": "保存中...",
|
||||
"understand": "明白了",
|
||||
"units": {
|
||||
"gb": "GB",
|
||||
"perGb": "/GB"
|
||||
@@ -705,7 +706,9 @@
|
||||
"resetted": "模板已恢复默认",
|
||||
"testSent": "测试邮件已发送",
|
||||
"variables": "可用变量",
|
||||
"clickToCopy": "点击复制"
|
||||
"clickToCopy": "点击复制",
|
||||
"previewDesktopOnly": "预览仅在桌面网页版可用",
|
||||
"previewNotAvailable": "预览不可用"
|
||||
},
|
||||
"payments": {
|
||||
"title": "支付验证",
|
||||
|
||||
16
src/main.tsx
16
src/main.tsx
@@ -3,8 +3,10 @@ import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App';
|
||||
import { PlatformProvider } from './platform/PlatformProvider';
|
||||
import { ThemeColorsProvider } from './providers/ThemeColorsProvider';
|
||||
import { ToastProvider } from './components/Toast';
|
||||
import { TooltipProvider } from './components/primitives/Tooltip';
|
||||
import { initLogoPreload } from './api/branding';
|
||||
import { initTelegramWebApp } from './hooks/useTelegramWebApp';
|
||||
import './i18n';
|
||||
@@ -29,11 +31,15 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<ThemeColorsProvider>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</ThemeColorsProvider>
|
||||
<PlatformProvider>
|
||||
<ThemeColorsProvider>
|
||||
<TooltipProvider>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</TooltipProvider>
|
||||
</ThemeColorsProvider>
|
||||
</PlatformProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
adminAppsApi,
|
||||
AppDefinition,
|
||||
@@ -13,19 +12,9 @@ import {
|
||||
RemnawaveConfig,
|
||||
importFromRemnawaveFormat as convertRemnawave,
|
||||
} from '../api/adminApps';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
// Icons
|
||||
const BackIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const AppsIcon = () => (
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
@@ -87,7 +76,7 @@ const CopyIcon = () => (
|
||||
|
||||
const StarIcon = ({ filled }: { filled: boolean }) => (
|
||||
<svg
|
||||
className={`h-4 w-4 ${filled ? 'fill-yellow-400 text-yellow-400' : 'text-dark-500'}`}
|
||||
className={`h-4 w-4 ${filled ? 'fill-warning-400 text-warning-400' : 'text-dark-500'}`}
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
@@ -816,12 +805,7 @@ export default function AdminApps() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
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>
|
||||
<AdminBackButton />
|
||||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('admin.apps.title')}</h1>
|
||||
</div>
|
||||
|
||||
|
||||
363
src/pages/AdminBroadcastDetail.tsx
Normal file
363
src/pages/AdminBroadcastDetail.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { adminBroadcastsApi, type BroadcastChannel } from '../api/adminBroadcasts';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
// Icons
|
||||
|
||||
const StopIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M5.25 7.5A2.25 2.25 0 017.5 5.25h9a2.25 2.25 0 012.25 2.25v9a2.25 2.25 0 01-2.25 2.25h-9a2.25 2.25 0 01-2.25-2.25v-9z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PhotoIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const VideoIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DocumentIcon = () => (
|
||||
<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 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const RefreshIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const TelegramIcon = () => (
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const EmailIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Channel badge component
|
||||
function ChannelBadge({ channel }: { channel?: BroadcastChannel }) {
|
||||
if (!channel || channel === 'telegram') {
|
||||
return (
|
||||
<span className="flex items-center gap-1 rounded-full bg-blue-500/20 px-2 py-0.5 text-xs text-blue-400">
|
||||
<TelegramIcon />
|
||||
<span className="hidden sm:inline">Telegram</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (channel === 'email') {
|
||||
return (
|
||||
<span className="flex items-center gap-1 rounded-full bg-purple-500/20 px-2 py-0.5 text-xs text-purple-400">
|
||||
<EmailIcon />
|
||||
<span className="hidden sm:inline">Email</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-1 rounded-full bg-green-500/20 px-2 py-0.5 text-xs text-green-400">
|
||||
<TelegramIcon />
|
||||
<span className="mx-0.5">+</span>
|
||||
<EmailIcon />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Status badge component
|
||||
const statusConfig: Record<string, { bg: string; text: string; labelKey: string }> = {
|
||||
queued: {
|
||||
bg: 'bg-warning-500/20',
|
||||
text: 'text-warning-400',
|
||||
labelKey: 'admin.broadcasts.status.queued',
|
||||
},
|
||||
in_progress: {
|
||||
bg: 'bg-accent-500/20',
|
||||
text: 'text-accent-400',
|
||||
labelKey: 'admin.broadcasts.status.inProgress',
|
||||
},
|
||||
completed: {
|
||||
bg: 'bg-success-500/20',
|
||||
text: 'text-success-400',
|
||||
labelKey: 'admin.broadcasts.status.completed',
|
||||
},
|
||||
partial: {
|
||||
bg: 'bg-warning-500/20',
|
||||
text: 'text-warning-400',
|
||||
labelKey: 'admin.broadcasts.status.partial',
|
||||
},
|
||||
failed: {
|
||||
bg: 'bg-error-500/20',
|
||||
text: 'text-error-400',
|
||||
labelKey: 'admin.broadcasts.status.failed',
|
||||
},
|
||||
cancelled: {
|
||||
bg: 'bg-dark-500/20',
|
||||
text: 'text-dark-400',
|
||||
labelKey: 'admin.broadcasts.status.cancelled',
|
||||
},
|
||||
cancelling: {
|
||||
bg: 'bg-warning-500/20',
|
||||
text: 'text-warning-400',
|
||||
labelKey: 'admin.broadcasts.status.cancelling',
|
||||
},
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const { t } = useTranslation();
|
||||
const config = statusConfig[status] || statusConfig.queued;
|
||||
return (
|
||||
<span className={`rounded-full px-3 py-1 text-sm font-medium ${config.bg} ${config.text}`}>
|
||||
{t(config.labelKey)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminBroadcastDetail() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
const broadcastId = id ? parseInt(id, 10) : null;
|
||||
|
||||
// Fetch broadcast details
|
||||
const {
|
||||
data: broadcast,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ['admin', 'broadcasts', 'detail', broadcastId],
|
||||
queryFn: async () => {
|
||||
if (!broadcastId) throw new Error('Invalid broadcast ID');
|
||||
const response = await adminBroadcastsApi.list(100, 0);
|
||||
const found = response.items.find((b) => b.id === broadcastId);
|
||||
if (!found) throw new Error('Broadcast not found');
|
||||
return found;
|
||||
},
|
||||
enabled: !!broadcastId && !isNaN(broadcastId),
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data;
|
||||
if (data && ['queued', 'in_progress', 'cancelling'].includes(data.status)) {
|
||||
return 3000;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
// Stop mutation
|
||||
const stopMutation = useMutation({
|
||||
mutationFn: adminBroadcastsApi.stop,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] });
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const isRunning = broadcast && ['queued', 'in_progress', 'cancelling'].includes(broadcast.status);
|
||||
|
||||
if (!broadcastId || isNaN(broadcastId)) {
|
||||
navigate('/admin/broadcasts');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!broadcast) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4">
|
||||
<p className="text-dark-400">{t('admin.broadcasts.notFound')}</p>
|
||||
<button
|
||||
onClick={() => navigate('/admin/broadcasts')}
|
||||
className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
{t('common.back')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<AdminBackButton to="/admin/broadcasts" />
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-bold text-dark-100">
|
||||
{t('admin.broadcasts.detail')} #{broadcast.id}
|
||||
</h1>
|
||||
<StatusBadge status={broadcast.status} />
|
||||
<ChannelBadge channel={broadcast.channel} />
|
||||
</div>
|
||||
<p className="text-sm text-dark-400">
|
||||
{new Date(broadcast.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="rounded-lg p-2 transition-colors hover:bg-dark-700"
|
||||
>
|
||||
<RefreshIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{isRunning && (
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
|
||||
<div className="mb-2 flex justify-between text-sm">
|
||||
<span className="text-dark-400">{t('admin.broadcasts.progress')}</span>
|
||||
<span className="font-medium text-dark-100">
|
||||
{broadcast.progress_percent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 overflow-hidden rounded-full bg-dark-700">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-accent-500 to-accent-400 transition-all duration-300"
|
||||
style={{ width: `${broadcast.progress_percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4 text-center">
|
||||
<p className="text-3xl font-bold text-dark-100">{broadcast.total_count}</p>
|
||||
<p className="text-sm text-dark-400">{t('admin.broadcasts.total')}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-success-500/30 bg-success-500/10 p-4 text-center">
|
||||
<p className="text-3xl font-bold text-success-400">{broadcast.sent_count}</p>
|
||||
<p className="text-sm text-dark-400">{t('admin.broadcasts.sent')}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-4 text-center">
|
||||
<p className="text-3xl font-bold text-error-400">{broadcast.failed_count}</p>
|
||||
<p className="text-sm text-dark-400">{t('admin.broadcasts.failed')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Target */}
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
|
||||
<p className="mb-1 text-sm text-dark-400">{t('admin.broadcasts.filter')}</p>
|
||||
<p className="font-medium text-dark-100">{broadcast.target_type}</p>
|
||||
</div>
|
||||
|
||||
{/* Telegram Message */}
|
||||
{broadcast.message_text && (
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
|
||||
<p className="mb-2 flex items-center gap-2 text-sm text-dark-400">
|
||||
<TelegramIcon />
|
||||
{t('admin.broadcasts.message')}
|
||||
</p>
|
||||
<div className="max-h-60 overflow-y-auto whitespace-pre-wrap rounded-lg bg-dark-700/50 p-4 text-dark-100">
|
||||
{broadcast.message_text}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Subject */}
|
||||
{broadcast.email_subject && (
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
|
||||
<p className="mb-2 flex items-center gap-2 text-sm text-dark-400">
|
||||
<EmailIcon />
|
||||
{t('admin.broadcasts.emailSubject')}
|
||||
</p>
|
||||
<div className="rounded-lg bg-dark-700/50 p-4 text-dark-100">
|
||||
{broadcast.email_subject}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Content */}
|
||||
{broadcast.email_html_content && (
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
|
||||
<p className="mb-2 text-sm text-dark-400">{t('admin.broadcasts.emailContent')}</p>
|
||||
<div className="max-h-60 overflow-y-auto whitespace-pre-wrap rounded-lg bg-dark-700/50 p-4 font-mono text-xs text-dark-100">
|
||||
{broadcast.email_html_content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Media */}
|
||||
{broadcast.has_media && (
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
|
||||
<p className="mb-2 text-sm text-dark-400">{t('admin.broadcasts.media')}</p>
|
||||
<div className="flex items-center gap-3 text-dark-100">
|
||||
{broadcast.media_type === 'photo' && <PhotoIcon />}
|
||||
{broadcast.media_type === 'video' && <VideoIcon />}
|
||||
{broadcast.media_type === 'document' && <DocumentIcon />}
|
||||
<span className="capitalize">{broadcast.media_type}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin info */}
|
||||
<div className="flex justify-between rounded-xl border border-dark-700 bg-dark-800/50 p-4 text-sm">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.broadcasts.createdBy')}:{' '}
|
||||
<span className="text-dark-100">
|
||||
{broadcast.admin_name || t('admin.broadcasts.unknownAdmin')}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-dark-400">{new Date(broadcast.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
{/* Stop button */}
|
||||
{isRunning && broadcast.status !== 'cancelling' && (
|
||||
<button
|
||||
onClick={() => stopMutation.mutate(broadcast.id)}
|
||||
disabled={stopMutation.isPending}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl border border-error-500/30 bg-error-500/20 py-3 text-error-400 transition-colors hover:bg-error-500/30 disabled:opacity-50"
|
||||
>
|
||||
<StopIcon />
|
||||
{stopMutation.isPending ? t('admin.broadcasts.stopping') : t('admin.broadcasts.stop')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import i18n from '../i18n';
|
||||
import {
|
||||
campaignsApi,
|
||||
@@ -15,19 +14,9 @@ import {
|
||||
TariffListItem,
|
||||
CampaignRegistrationItem,
|
||||
} from '../api/campaigns';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
// Icons
|
||||
const BackIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PlusIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
@@ -114,23 +103,23 @@ const bonusTypeConfig: Record<
|
||||
> = {
|
||||
balance: {
|
||||
labelKey: 'admin.campaigns.bonusType.balance',
|
||||
color: 'text-emerald-400',
|
||||
bgColor: 'bg-emerald-500/20',
|
||||
color: 'text-success-400',
|
||||
bgColor: 'bg-success-500/20',
|
||||
},
|
||||
subscription: {
|
||||
labelKey: 'admin.campaigns.bonusType.subscription',
|
||||
color: 'text-blue-400',
|
||||
bgColor: 'bg-blue-500/20',
|
||||
color: 'text-accent-400',
|
||||
bgColor: 'bg-accent-500/20',
|
||||
},
|
||||
tariff: {
|
||||
labelKey: 'admin.campaigns.bonusType.tariff',
|
||||
color: 'text-purple-400',
|
||||
bgColor: 'bg-purple-500/20',
|
||||
color: 'text-accent-400',
|
||||
bgColor: 'bg-accent-500/20',
|
||||
},
|
||||
none: {
|
||||
labelKey: 'admin.campaigns.bonusType.none',
|
||||
color: 'text-gray-400',
|
||||
bgColor: 'bg-gray-500/20',
|
||||
color: 'text-dark-400',
|
||||
bgColor: 'bg-dark-500/20',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -310,8 +299,8 @@ function CampaignModal({
|
||||
|
||||
{/* Bonus Settings */}
|
||||
{bonusType === 'balance' && (
|
||||
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-4">
|
||||
<label className="mb-2 block text-sm font-medium text-emerald-400">
|
||||
<div className="rounded-lg border border-success-500/30 bg-success-500/10 p-4">
|
||||
<label className="mb-2 block text-sm font-medium text-success-400">
|
||||
{t('admin.campaigns.form.balanceBonus')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -321,7 +310,7 @@ function CampaignModal({
|
||||
onChange={(e) =>
|
||||
setBalanceBonusRubles(Math.max(0, parseFloat(e.target.value) || 0))
|
||||
}
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-emerald-500 focus:outline-none"
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-success-500 focus:outline-none"
|
||||
min={0}
|
||||
step={1}
|
||||
/>
|
||||
@@ -331,8 +320,8 @@ function CampaignModal({
|
||||
)}
|
||||
|
||||
{bonusType === 'subscription' && (
|
||||
<div className="space-y-3 rounded-lg border border-blue-500/30 bg-blue-500/10 p-4">
|
||||
<label className="block text-sm font-medium text-blue-400">
|
||||
<div className="space-y-3 rounded-lg border border-accent-500/30 bg-accent-500/10 p-4">
|
||||
<label className="block text-sm font-medium text-accent-400">
|
||||
{t('admin.campaigns.form.trialSubscription')}
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
@@ -346,7 +335,7 @@ function CampaignModal({
|
||||
onChange={(e) =>
|
||||
setSubscriptionDays(Math.max(1, parseInt(e.target.value) || 1))
|
||||
}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-blue-500 focus:outline-none"
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
@@ -360,7 +349,7 @@ function CampaignModal({
|
||||
onChange={(e) =>
|
||||
setSubscriptionTraffic(Math.max(0, parseInt(e.target.value) || 0))
|
||||
}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-blue-500 focus:outline-none"
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
@@ -374,7 +363,7 @@ function CampaignModal({
|
||||
onChange={(e) =>
|
||||
setSubscriptionDevices(Math.max(1, parseInt(e.target.value) || 1))
|
||||
}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-blue-500 focus:outline-none"
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
@@ -392,14 +381,14 @@ function CampaignModal({
|
||||
onClick={() => toggleServer(server.squad_uuid)}
|
||||
className={`flex w-full items-center gap-2 rounded-lg p-2 text-left transition-colors ${
|
||||
selectedSquads.includes(server.squad_uuid)
|
||||
? 'bg-blue-500/20 text-blue-300'
|
||||
? 'bg-accent-500/20 text-accent-300'
|
||||
: 'bg-dark-600 text-dark-300 hover:bg-dark-500'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-4 w-4 items-center justify-center rounded ${
|
||||
selectedSquads.includes(server.squad_uuid)
|
||||
? 'bg-blue-500 text-white'
|
||||
? 'bg-accent-500 text-white'
|
||||
: 'bg-dark-500'
|
||||
}`}
|
||||
>
|
||||
@@ -415,8 +404,8 @@ function CampaignModal({
|
||||
)}
|
||||
|
||||
{bonusType === 'tariff' && (
|
||||
<div className="space-y-3 rounded-lg border border-purple-500/30 bg-purple-500/10 p-4">
|
||||
<label className="block text-sm font-medium text-purple-400">
|
||||
<div className="space-y-3 rounded-lg border border-accent-500/30 bg-accent-500/10 p-4">
|
||||
<label className="block text-sm font-medium text-accent-400">
|
||||
{t('admin.campaigns.form.tariff')}
|
||||
</label>
|
||||
<div>
|
||||
@@ -426,7 +415,7 @@ function CampaignModal({
|
||||
<select
|
||||
value={tariffId || ''}
|
||||
onChange={(e) => setTariffId(e.target.value ? parseInt(e.target.value) : null)}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-purple-500 focus:outline-none"
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
>
|
||||
<option value="">{t('admin.campaigns.form.notSelected')}</option>
|
||||
{tariffs.map((tariff) => (
|
||||
@@ -449,7 +438,7 @@ function CampaignModal({
|
||||
type="number"
|
||||
value={tariffDays}
|
||||
onChange={(e) => setTariffDays(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-purple-500 focus:outline-none"
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
@@ -457,7 +446,7 @@ function CampaignModal({
|
||||
)}
|
||||
|
||||
{bonusType === 'none' && (
|
||||
<div className="rounded-lg border border-gray-500/30 bg-gray-500/10 p-4">
|
||||
<div className="rounded-lg border border-dark-500/30 bg-dark-500/10 p-4">
|
||||
<p className="text-sm text-dark-400">
|
||||
{t('admin.campaigns.form.noBonusDescription')}
|
||||
</p>
|
||||
@@ -584,17 +573,17 @@ function StatsModal({ stats, onClose, onViewUsers }: StatsModalProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700 p-3 text-center">
|
||||
<div className="text-2xl font-bold text-emerald-400">
|
||||
<div className="text-2xl font-bold text-success-400">
|
||||
{formatRubles(stats.total_revenue_kopeks)}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">{t('admin.campaigns.stats.revenue')}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700 p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-400">{stats.paid_users_count}</div>
|
||||
<div className="text-2xl font-bold text-accent-400">{stats.paid_users_count}</div>
|
||||
<div className="text-xs text-dark-500">{t('admin.campaigns.stats.paidUsers')}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700 p-3 text-center">
|
||||
<div className="text-2xl font-bold text-purple-400">{stats.conversion_rate}%</div>
|
||||
<div className="text-2xl font-bold text-accent-400">{stats.conversion_rate}%</div>
|
||||
<div className="text-xs text-dark-500">{t('admin.campaigns.stats.conversion')}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -606,19 +595,19 @@ function StatsModal({ stats, onClose, onViewUsers }: StatsModalProps) {
|
||||
{t('admin.campaigns.stats.bonusesIssued')}
|
||||
</div>
|
||||
{stats.bonus_type === 'balance' && (
|
||||
<div className="text-lg font-medium text-emerald-400">
|
||||
<div className="text-lg font-medium text-success-400">
|
||||
{formatRubles(stats.balance_issued_kopeks)}
|
||||
</div>
|
||||
)}
|
||||
{stats.bonus_type === 'subscription' && (
|
||||
<div className="text-lg font-medium text-blue-400">
|
||||
<div className="text-lg font-medium text-accent-400">
|
||||
{t('admin.campaigns.stats.subscriptionsIssued', {
|
||||
count: stats.subscription_issued,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{stats.bonus_type === 'tariff' && (
|
||||
<div className="text-lg font-medium text-purple-400">
|
||||
<div className="text-lg font-medium text-accent-400">
|
||||
{t('admin.campaigns.stats.tariffsIssued', { count: stats.subscription_issued })}
|
||||
</div>
|
||||
)}
|
||||
@@ -792,12 +781,12 @@ function UsersModal({ campaignId, campaignName, onClose }: UsersModalProps) {
|
||||
<td className="p-3">
|
||||
<div className="flex gap-1">
|
||||
{reg.has_subscription && (
|
||||
<span className="rounded bg-blue-500/20 px-1.5 py-0.5 text-xs text-blue-400">
|
||||
<span className="rounded bg-accent-500/20 px-1.5 py-0.5 text-xs text-accent-400">
|
||||
VPN
|
||||
</span>
|
||||
)}
|
||||
{reg.has_paid && (
|
||||
<span className="rounded bg-emerald-500/20 px-1.5 py-0.5 text-xs text-emerald-400">
|
||||
<span className="rounded bg-success-500/20 px-1.5 py-0.5 text-xs text-success-400">
|
||||
{t('admin.campaigns.users.paid')}
|
||||
</span>
|
||||
)}
|
||||
@@ -945,12 +934,7 @@ export default function AdminCampaigns() {
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
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>
|
||||
<AdminBackButton />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">{t('admin.campaigns.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.campaigns.subtitle')}</p>
|
||||
@@ -982,13 +966,13 @@ export default function AdminCampaigns() {
|
||||
<div className="text-sm text-dark-400">{t('admin.campaigns.overview.active')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-blue-400">{overview.total_registrations}</div>
|
||||
<div className="text-2xl font-bold text-accent-400">{overview.total_registrations}</div>
|
||||
<div className="text-sm text-dark-400">
|
||||
{t('admin.campaigns.overview.registrations')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-emerald-400">
|
||||
<div className="text-2xl font-bold text-success-400">
|
||||
{formatRubles(overview.total_balance_issued_kopeks)}
|
||||
</div>
|
||||
<div className="text-sm text-dark-400">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
statsApi,
|
||||
type DashboardStats,
|
||||
@@ -10,19 +9,9 @@ import {
|
||||
type RecentPaymentsResponse,
|
||||
} from '../api/admin';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
// Icons - styled like main navigation
|
||||
const BackIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ServerIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
@@ -461,12 +450,7 @@ export default function AdminDashboard() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
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>
|
||||
<AdminBackButton />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-dark-100">{t('adminDashboard.title')}</h1>
|
||||
<p className="text-dark-400">{t('adminDashboard.subtitle')}</p>
|
||||
|
||||
113
src/pages/AdminEmailTemplatePreview.tsx
Normal file
113
src/pages/AdminEmailTemplatePreview.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { adminEmailTemplatesApi } from '../api/adminEmailTemplates';
|
||||
import { BackIcon } from '../components/admin';
|
||||
|
||||
interface PreviewState {
|
||||
subject: string;
|
||||
body_html: string;
|
||||
}
|
||||
|
||||
export default function AdminEmailTemplatePreview() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { type, lang } = useParams<{ type: string; lang: string }>();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const [previewHtml, setPreviewHtml] = useState<string>('');
|
||||
|
||||
// Get data from navigation state
|
||||
const state = location.state as PreviewState | null;
|
||||
|
||||
// Preview mutation
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!type || !lang || !state) {
|
||||
throw new Error('Missing required data');
|
||||
}
|
||||
return adminEmailTemplatesApi.previewTemplate(type, {
|
||||
language: lang,
|
||||
subject: state.subject,
|
||||
body_html: state.body_html,
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setPreviewHtml(data.body_html);
|
||||
},
|
||||
});
|
||||
|
||||
// Load preview on mount
|
||||
useEffect(() => {
|
||||
if (!type || !lang || !state) {
|
||||
navigate('/admin/email-templates');
|
||||
return;
|
||||
}
|
||||
previewMutation.mutate();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [type, lang]);
|
||||
|
||||
// Write preview HTML into iframe
|
||||
useEffect(() => {
|
||||
if (previewHtml && iframeRef.current) {
|
||||
const doc = iframeRef.current.contentDocument;
|
||||
if (doc) {
|
||||
doc.open();
|
||||
doc.write(previewHtml);
|
||||
doc.close();
|
||||
}
|
||||
}
|
||||
}, [previewHtml]);
|
||||
|
||||
if (!type || !lang || !state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-120px)] flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="rounded-xl border border-dark-700 bg-dark-800 p-2 transition-colors hover:bg-dark-700"
|
||||
>
|
||||
<BackIcon />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-dark-100">{t('admin.emailTemplates.preview')}</h1>
|
||||
<p className="text-sm text-dark-400">
|
||||
{type} / {lang.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview content */}
|
||||
<div className="flex-1 overflow-hidden rounded-xl border border-dark-700 bg-white">
|
||||
{previewMutation.isPending ? (
|
||||
<div className="flex h-full items-center justify-center bg-dark-800">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
) : previewMutation.isError ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 bg-dark-800">
|
||||
<p className="text-dark-400">{t('common.error')}</p>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
{t('common.back')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className="h-full w-full"
|
||||
sandbox="allow-same-origin"
|
||||
title="Email Preview"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,33 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
adminEmailTemplatesApi,
|
||||
EmailTemplateType,
|
||||
EmailTemplateDetail,
|
||||
EmailTemplateLanguageData,
|
||||
} from '../api/adminEmailTemplates';
|
||||
import { AdminBackButton, BackIcon } from '../components/admin';
|
||||
import { useIsTelegram } from '../platform/hooks/usePlatform';
|
||||
|
||||
// Hook to check if on mobile
|
||||
function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = useState(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return window.innerWidth < 1024;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => setIsMobile(window.innerWidth < 1024);
|
||||
window.addEventListener('resize', check);
|
||||
return () => window.removeEventListener('resize', check);
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
// Icons
|
||||
const BackIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MailIcon = () => (
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
@@ -69,12 +76,6 @@ const ResetIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const XIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const LANG_LABELS: Record<string, string> = {
|
||||
ru: 'RU',
|
||||
en: 'EN',
|
||||
@@ -156,16 +157,22 @@ function TemplateEditor({
|
||||
currentLang: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const isTelegram = useIsTelegram();
|
||||
const isMobile = useIsMobile();
|
||||
const isPreviewDisabled = isTelegram || isMobile;
|
||||
|
||||
const [activeLang, setActiveLang] = useState('ru');
|
||||
const [editSubject, setEditSubject] = useState('');
|
||||
const [editBody, setEditBody] = useState('');
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [previewHtml, setPreviewHtml] = useState('');
|
||||
const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||
const [toast, setToast] = useState<{
|
||||
type: 'success' | 'error' | 'info';
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const [showPreviewAlert, setShowPreviewAlert] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const langData: EmailTemplateLanguageData | undefined = detail.languages[activeLang];
|
||||
|
||||
@@ -178,7 +185,7 @@ function TemplateEditor({
|
||||
}
|
||||
}, [activeLang, langData]);
|
||||
|
||||
const showToast = useCallback((type: 'success' | 'error', message: string) => {
|
||||
const showToast = useCallback((type: 'success' | 'error' | 'info', message: string) => {
|
||||
setToast({ type, message });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
}, []);
|
||||
@@ -245,23 +252,6 @@ function TemplateEditor({
|
||||
},
|
||||
});
|
||||
|
||||
// Preview mutation
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
adminEmailTemplatesApi.previewTemplate(detail.notification_type, {
|
||||
language: activeLang,
|
||||
subject: editSubject,
|
||||
body_html: editBody,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
setPreviewHtml(data.body_html);
|
||||
setShowPreview(true);
|
||||
},
|
||||
onError: () => {
|
||||
showToast('error', t('common.error'));
|
||||
},
|
||||
});
|
||||
|
||||
// Send test mutation
|
||||
const testMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
@@ -276,18 +266,6 @@ function TemplateEditor({
|
||||
},
|
||||
});
|
||||
|
||||
// Write preview HTML into iframe
|
||||
useEffect(() => {
|
||||
if (showPreview && iframeRef.current && previewHtml) {
|
||||
const doc = iframeRef.current.contentDocument;
|
||||
if (doc) {
|
||||
doc.open();
|
||||
doc.write(previewHtml);
|
||||
doc.close();
|
||||
}
|
||||
}
|
||||
}, [showPreview, previewHtml]);
|
||||
|
||||
const handleSubjectChange = (value: string) => {
|
||||
setEditSubject(value);
|
||||
setIsDirty(true);
|
||||
@@ -421,9 +399,19 @@ function TemplateEditor({
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => previewMutation.mutate()}
|
||||
disabled={previewMutation.isPending}
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-dark-700 px-3 py-2.5 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-600 disabled:opacity-40 sm:px-4 sm:py-2"
|
||||
onClick={() => {
|
||||
if (isPreviewDisabled) {
|
||||
setShowPreviewAlert(true);
|
||||
return;
|
||||
}
|
||||
navigate(`/admin/email-templates/preview/${detail.notification_type}/${activeLang}`, {
|
||||
state: {
|
||||
subject: editSubject,
|
||||
body_html: editBody,
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-dark-700 px-3 py-2.5 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-600 sm:px-4 sm:py-2"
|
||||
>
|
||||
<EyeIcon />
|
||||
{t('admin.emailTemplates.preview')}
|
||||
@@ -460,36 +448,63 @@ function TemplateEditor({
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div
|
||||
className={`fixed bottom-4 left-4 right-4 z-50 animate-fade-in rounded-xl px-4 py-3 text-center text-sm font-medium shadow-lg sm:bottom-6 sm:left-auto sm:right-6 sm:text-left ${
|
||||
toast.type === 'success' ? 'bg-emerald-500/90 text-white' : 'bg-red-500/90 text-white'
|
||||
className={`fixed left-4 right-4 top-32 z-[100] mx-auto max-w-sm animate-fade-in rounded-xl px-4 py-3 text-center text-sm font-medium shadow-lg sm:left-1/2 sm:right-auto sm:-translate-x-1/2 ${
|
||||
toast.type === 'success'
|
||||
? 'bg-success-500 text-white'
|
||||
: toast.type === 'info'
|
||||
? 'bg-dark-700 text-dark-100 ring-1 ring-dark-600'
|
||||
: 'bg-error-500 text-white'
|
||||
}`}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview Modal */}
|
||||
{showPreview && (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-sm sm:items-center sm:p-4">
|
||||
<div className="flex max-h-[90vh] w-full flex-col rounded-t-2xl border border-dark-600 bg-dark-800 shadow-2xl sm:max-h-[85vh] sm:max-w-2xl sm:rounded-2xl">
|
||||
<div className="flex items-center justify-between border-b border-dark-700 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<h3 className="text-base font-semibold text-dark-100">
|
||||
{t('admin.emailTemplates.preview')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowPreview(false)}
|
||||
className="rounded-lg p-1.5 transition-colors hover:bg-dark-700"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
{/* Preview Disabled Alert */}
|
||||
{showPreviewAlert && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
onClick={() => setShowPreviewAlert(false)}
|
||||
/>
|
||||
{/* Alert Modal */}
|
||||
<div className="relative w-full max-w-sm overflow-hidden rounded-2xl border border-dark-700 bg-dark-800 shadow-2xl">
|
||||
{/* Icon */}
|
||||
<div className="flex justify-center pt-6">
|
||||
<div className="rounded-full bg-warning-500/20 p-4">
|
||||
<svg
|
||||
className="h-8 w-8 text-warning-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden p-1">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className="h-full min-h-[50vh] w-full rounded-lg bg-white sm:min-h-[400px]"
|
||||
sandbox="allow-same-origin"
|
||||
title="Email Preview"
|
||||
/>
|
||||
{/* Content */}
|
||||
<div className="px-6 pb-2 pt-4 text-center">
|
||||
<h3 className="text-lg font-semibold text-dark-100">
|
||||
{t('admin.emailTemplates.previewNotAvailable')}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-dark-400">
|
||||
{t('admin.emailTemplates.previewDesktopOnly')}
|
||||
</p>
|
||||
</div>
|
||||
{/* Button */}
|
||||
<div className="p-6 pt-4">
|
||||
<button
|
||||
onClick={() => setShowPreviewAlert(false)}
|
||||
className="w-full rounded-xl bg-dark-700 py-3 text-sm font-medium text-dark-100 transition-colors hover:bg-dark-600"
|
||||
>
|
||||
{t('common.understand')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -522,14 +537,9 @@ export default function AdminEmailTemplates() {
|
||||
<div className="mx-auto max-w-4xl space-y-4 px-3 py-4 sm:space-y-6 sm:px-4 sm:py-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="flex-shrink-0 rounded-xl border border-dark-700 bg-dark-800 p-1.5 transition-colors hover:bg-dark-700 sm:p-2"
|
||||
>
|
||||
<BackIcon />
|
||||
</Link>
|
||||
<AdminBackButton className="flex-shrink-0 rounded-xl border border-dark-700 bg-dark-800 p-1.5 transition-colors hover:bg-dark-700 sm:p-2" />
|
||||
<div className="flex min-w-0 items-center gap-2 sm:gap-2.5">
|
||||
<div className="flex-shrink-0 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 p-1.5 text-blue-400 sm:p-2">
|
||||
<div className="flex-shrink-0 rounded-xl bg-gradient-to-br from-accent-500/20 to-accent-600/10 p-1.5 text-accent-400 sm:p-2">
|
||||
<MailIcon />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -299,10 +299,10 @@ export default function AdminPanel() {
|
||||
id: 'analytics',
|
||||
title: t('admin.groups.analytics'),
|
||||
icon: <AnalyticsGroupIcon />,
|
||||
gradient: 'from-emerald-500/10 to-emerald-500/5',
|
||||
borderColor: 'border-emerald-500/20',
|
||||
iconBg: 'bg-emerald-500/20',
|
||||
iconColor: 'text-emerald-400',
|
||||
gradient: 'from-success-500/10 to-success-500/5',
|
||||
borderColor: 'border-success-500/20',
|
||||
iconBg: 'bg-success-500/20',
|
||||
iconColor: 'text-success-400',
|
||||
items: [
|
||||
{
|
||||
to: '/admin/dashboard',
|
||||
@@ -322,10 +322,10 @@ export default function AdminPanel() {
|
||||
id: 'users',
|
||||
title: t('admin.groups.users'),
|
||||
icon: <UsersGroupIcon />,
|
||||
gradient: 'from-blue-500/10 to-blue-500/5',
|
||||
borderColor: 'border-blue-500/20',
|
||||
iconBg: 'bg-blue-500/20',
|
||||
iconColor: 'text-blue-400',
|
||||
gradient: 'from-accent-500/10 to-accent-500/5',
|
||||
borderColor: 'border-accent-500/20',
|
||||
iconBg: 'bg-accent-500/20',
|
||||
iconColor: 'text-accent-400',
|
||||
items: [
|
||||
{
|
||||
to: '/admin/users',
|
||||
@@ -351,10 +351,10 @@ export default function AdminPanel() {
|
||||
id: 'tariffs',
|
||||
title: t('admin.groups.tariffs'),
|
||||
icon: <TariffsGroupIcon />,
|
||||
gradient: 'from-amber-500/10 to-amber-500/5',
|
||||
borderColor: 'border-amber-500/20',
|
||||
iconBg: 'bg-amber-500/20',
|
||||
iconColor: 'text-amber-400',
|
||||
gradient: 'from-warning-500/10 to-warning-500/5',
|
||||
borderColor: 'border-warning-500/20',
|
||||
iconBg: 'bg-warning-500/20',
|
||||
iconColor: 'text-warning-400',
|
||||
items: [
|
||||
{
|
||||
to: '/admin/tariffs',
|
||||
@@ -386,10 +386,10 @@ export default function AdminPanel() {
|
||||
id: 'marketing',
|
||||
title: t('admin.groups.marketing'),
|
||||
icon: <MarketingGroupIcon />,
|
||||
gradient: 'from-rose-500/10 to-rose-500/5',
|
||||
borderColor: 'border-rose-500/20',
|
||||
iconBg: 'bg-rose-500/20',
|
||||
iconColor: 'text-rose-400',
|
||||
gradient: 'from-error-500/10 to-error-500/5',
|
||||
borderColor: 'border-error-500/20',
|
||||
iconBg: 'bg-error-500/20',
|
||||
iconColor: 'text-error-400',
|
||||
items: [
|
||||
{
|
||||
to: '/admin/campaigns',
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
@@ -21,22 +18,11 @@ import {
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
import type { PaymentMethodConfig, PromoGroupSimple } from '../types';
|
||||
|
||||
// ============ Icons ============
|
||||
|
||||
const BackIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const GripIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
@@ -120,11 +106,11 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
|
||||
id: config.method_id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 50 : undefined,
|
||||
opacity: isDragging ? 0.85 : 1,
|
||||
position: isDragging ? 'relative' : undefined,
|
||||
};
|
||||
|
||||
const displayName = config.display_name || config.default_display_name;
|
||||
@@ -159,19 +145,19 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`group flex items-center gap-3 rounded-xl border p-4 transition-all ${
|
||||
className={`group flex items-center gap-3 rounded-xl border p-4 ${
|
||||
isDragging
|
||||
? 'border-accent-500/50 bg-dark-700/80 shadow-xl shadow-accent-500/10'
|
||||
? 'border-accent-500/50 bg-dark-800 shadow-xl shadow-accent-500/20'
|
||||
: config.is_enabled
|
||||
? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
|
||||
: 'border-dark-800/50 bg-dark-900/30 opacity-60'
|
||||
}`}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
{/* Drag handle - larger touch target for mobile */}
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="flex-shrink-0 cursor-grab touch-manipulation rounded-lg p-1.5 text-dark-500 hover:bg-dark-700/50 hover:text-dark-300 active:cursor-grabbing"
|
||||
className="flex-shrink-0 cursor-grab touch-none rounded-lg p-2.5 text-dark-500 hover:bg-dark-700/50 hover:text-dark-300 active:cursor-grabbing sm:p-1.5"
|
||||
title={t('admin.paymentMethods.dragToReorder')}
|
||||
>
|
||||
<GripIcon />
|
||||
@@ -682,10 +668,9 @@ export default function AdminPaymentMethods() {
|
||||
},
|
||||
});
|
||||
|
||||
// DnD sensors
|
||||
// DnD sensors - PointerSensor handles both mouse and touch
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }),
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
@@ -717,12 +702,7 @@ export default function AdminPaymentMethods() {
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
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>
|
||||
<AdminBackButton />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-dark-50">{t('admin.paymentMethods.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.paymentMethods.description')}</p>
|
||||
@@ -757,11 +737,7 @@ export default function AdminPaymentMethods() {
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
) : methods.length > 0 ? (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||
<SortableContext
|
||||
items={methods.map((m) => m.method_id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { adminPaymentsApi } from '../api/adminPayments';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
import type { PendingPayment, PaginatedResponse } from '../types';
|
||||
|
||||
export default function AdminPayments() {
|
||||
@@ -68,20 +68,7 @@ export default function AdminPayments() {
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
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"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
</Link>
|
||||
<AdminBackButton />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-dark-50">{t('admin.payments.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.payments.description')}</p>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import i18n from '../i18n';
|
||||
import {
|
||||
promoOffersApi,
|
||||
@@ -14,19 +13,9 @@ import {
|
||||
OFFER_TYPE_CONFIG,
|
||||
OfferType,
|
||||
} from '../api/promoOffers';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
// Icons
|
||||
const BackIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const EditIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
@@ -116,9 +105,9 @@ const getActionLabel = (action: string): string => {
|
||||
|
||||
const getActionColor = (action: string): string => {
|
||||
const colors: Record<string, string> = {
|
||||
created: 'bg-blue-500/20 text-blue-400',
|
||||
claimed: 'bg-emerald-500/20 text-emerald-400',
|
||||
consumed: 'bg-purple-500/20 text-purple-400',
|
||||
created: 'bg-accent-500/20 text-accent-400',
|
||||
claimed: 'bg-success-500/20 text-success-400',
|
||||
consumed: 'bg-accent-500/20 text-accent-400',
|
||||
disabled: 'bg-dark-600 text-dark-400',
|
||||
};
|
||||
return colors[action] || 'bg-dark-600 text-dark-400';
|
||||
@@ -545,12 +534,12 @@ function ResultModal({ title, message, isSuccess, onClose }: ResultModalProps) {
|
||||
<div className="w-full max-w-sm rounded-xl bg-dark-800 p-6 text-center">
|
||||
<div
|
||||
className={`mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full ${
|
||||
isSuccess ? 'bg-emerald-500/20' : 'bg-error-500/20'
|
||||
isSuccess ? 'bg-success-500/20' : 'bg-error-500/20'
|
||||
}`}
|
||||
>
|
||||
{isSuccess ? (
|
||||
<svg
|
||||
className="h-8 w-8 text-emerald-400"
|
||||
className="h-8 w-8 text-success-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -688,12 +677,7 @@ export default function AdminPromoOffers() {
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
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>
|
||||
<AdminBackButton />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">{t('admin.promoOffers.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.promoOffers.subtitle')}</p>
|
||||
@@ -816,7 +800,7 @@ export default function AdminPromoOffers() {
|
||||
<div className="mt-3 border-t border-dark-700 pt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{template.is_active ? (
|
||||
<span className="rounded bg-emerald-500/20 px-2 py-0.5 text-xs text-emerald-400">
|
||||
<span className="rounded bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
|
||||
{t('admin.promoOffers.status.active')}
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import i18n from '../i18n';
|
||||
import {
|
||||
promocodesApi,
|
||||
@@ -14,19 +13,9 @@ import {
|
||||
PromoGroupCreateRequest,
|
||||
PromoGroupUpdateRequest,
|
||||
} from '../api/promocodes';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
// Icons
|
||||
const BackIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PlusIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
@@ -130,10 +119,10 @@ const getTypeLabel = (type: PromoCodeType): string => {
|
||||
|
||||
const getTypeColor = (type: PromoCodeType): string => {
|
||||
const colors: Record<PromoCodeType, string> = {
|
||||
balance: 'bg-emerald-500/20 text-emerald-400',
|
||||
subscription_days: 'bg-blue-500/20 text-blue-400',
|
||||
trial_subscription: 'bg-purple-500/20 text-purple-400',
|
||||
promo_group: 'bg-amber-500/20 text-amber-400',
|
||||
balance: 'bg-success-500/20 text-success-400',
|
||||
subscription_days: 'bg-accent-500/20 text-accent-400',
|
||||
trial_subscription: 'bg-accent-500/20 text-accent-400',
|
||||
promo_group: 'bg-warning-500/20 text-warning-400',
|
||||
discount: 'bg-pink-500/20 text-pink-400',
|
||||
};
|
||||
return colors[type] || 'bg-dark-600 text-dark-300';
|
||||
@@ -830,7 +819,7 @@ function PromocodeStatsModal({ promocode, onClose, onEdit }: PromocodeStatsModal
|
||||
<div className="text-sm text-dark-400">{t('admin.promocodes.stats.totalUses')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-700/50 p-4 text-center">
|
||||
<div className="mb-1 text-3xl font-bold text-emerald-400">{promocode.today_uses}</div>
|
||||
<div className="mb-1 text-3xl font-bold text-success-400">{promocode.today_uses}</div>
|
||||
<div className="text-sm text-dark-400">{t('admin.promocodes.stats.today')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-700/50 p-4 text-center">
|
||||
@@ -854,7 +843,7 @@ function PromocodeStatsModal({ promocode, onClose, onEdit }: PromocodeStatsModal
|
||||
{promocode.type === 'balance' && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">{t('admin.promocodes.stats.bonus')}:</span>
|
||||
<span className="text-emerald-400">
|
||||
<span className="text-success-400">
|
||||
+{promocode.balance_bonus_rubles} {t('admin.promocodes.form.rub')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -863,7 +852,7 @@ function PromocodeStatsModal({ promocode, onClose, onEdit }: PromocodeStatsModal
|
||||
promocode.type === 'trial_subscription') && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">{t('admin.promocodes.stats.daysLabel')}:</span>
|
||||
<span className="text-blue-400">+{promocode.subscription_days}</span>
|
||||
<span className="text-accent-400">+{promocode.subscription_days}</span>
|
||||
</div>
|
||||
)}
|
||||
{promocode.type === 'discount' && (
|
||||
@@ -892,7 +881,7 @@ function PromocodeStatsModal({ promocode, onClose, onEdit }: PromocodeStatsModal
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">{t('admin.promocodes.stats.status')}:</span>
|
||||
<span className={promocode.is_valid ? 'text-emerald-400' : 'text-error-400'}>
|
||||
<span className={promocode.is_valid ? 'text-success-400' : 'text-error-400'}>
|
||||
{promocode.is_valid
|
||||
? t('admin.promocodes.stats.active')
|
||||
: t('admin.promocodes.stats.inactive')}
|
||||
@@ -913,7 +902,7 @@ function PromocodeStatsModal({ promocode, onClose, onEdit }: PromocodeStatsModal
|
||||
{promocode.first_purchase_only && (
|
||||
<div className="col-span-2 flex justify-between">
|
||||
<span className="text-dark-400">{t('admin.promocodes.stats.restriction')}:</span>
|
||||
<span className="text-amber-400">
|
||||
<span className="text-warning-400">
|
||||
{t('admin.promocodes.stats.firstPurchaseOnly')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1119,12 +1108,7 @@ export default function AdminPromocodes() {
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
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>
|
||||
<AdminBackButton />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">{t('admin.promocodes.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.promocodes.subtitle')}</p>
|
||||
@@ -1159,7 +1143,7 @@ export default function AdminPromocodes() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-emerald-400">
|
||||
<div className="text-2xl font-bold text-success-400">
|
||||
{promocodes.filter((p) => p.is_active && p.is_valid).length}
|
||||
</div>
|
||||
<div className="text-xs text-dark-400">{t('admin.promocodes.stats.activeCount')}</div>
|
||||
@@ -1171,7 +1155,7 @@ export default function AdminPromocodes() {
|
||||
<div className="text-xs text-dark-400">{t('admin.promocodes.stats.usagesCount')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-amber-400">
|
||||
<div className="text-2xl font-bold text-warning-400">
|
||||
{promocodes.filter((p) => p.uses_left === 0 && p.max_uses > 0).length}
|
||||
</div>
|
||||
<div className="text-xs text-dark-400">{t('admin.promocodes.stats.exhausted')}</div>
|
||||
@@ -1242,20 +1226,20 @@ export default function AdminPromocodes() {
|
||||
</span>
|
||||
)}
|
||||
{promo.first_purchase_only && (
|
||||
<span className="rounded bg-amber-500/20 px-2 py-0.5 text-xs text-amber-400">
|
||||
<span className="rounded bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
|
||||
{t('admin.promocodes.firstPurchase')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
|
||||
{promo.type === 'balance' && (
|
||||
<span className="text-emerald-400">
|
||||
<span className="text-success-400">
|
||||
+{promo.balance_bonus_rubles} {t('admin.promocodes.form.rub')}
|
||||
</span>
|
||||
)}
|
||||
{(promo.type === 'subscription_days' ||
|
||||
promo.type === 'trial_subscription') && (
|
||||
<span className="text-blue-400">
|
||||
<span className="text-accent-400">
|
||||
+{promo.subscription_days} {t('admin.promocodes.form.days')}
|
||||
</span>
|
||||
)}
|
||||
@@ -1363,7 +1347,7 @@ export default function AdminPromocodes() {
|
||||
))}
|
||||
{group.auto_assign_total_spent_kopeks &&
|
||||
group.auto_assign_total_spent_kopeks > 0 && (
|
||||
<span className="text-amber-400">
|
||||
<span className="text-warning-400">
|
||||
{t('admin.promocodes.groups.autoFrom', {
|
||||
amount: group.auto_assign_total_spent_kopeks / 100,
|
||||
})}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
adminRemnawaveApi,
|
||||
NodeInfo,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
SystemStatsResponse,
|
||||
AutoSyncStatus,
|
||||
} from '../api/adminRemnawave';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
// ============ Icons ============
|
||||
|
||||
@@ -144,18 +144,6 @@ const ArrowPathIcon = ({ className = 'w-4 h-4' }: { className?: string }) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ChevronLeftIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ============ Helpers ============
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
@@ -232,11 +220,11 @@ interface StatCardProps {
|
||||
function StatCard({ label, value, icon, color = 'accent', subValue }: StatCardProps) {
|
||||
const colorClasses: Record<string, string> = {
|
||||
accent: 'bg-accent-500/20 text-accent-400',
|
||||
green: 'bg-emerald-500/20 text-emerald-400',
|
||||
blue: 'bg-blue-500/20 text-blue-400',
|
||||
orange: 'bg-orange-500/20 text-orange-400',
|
||||
red: 'bg-red-500/20 text-red-400',
|
||||
purple: 'bg-purple-500/20 text-purple-400',
|
||||
green: 'bg-success-500/20 text-success-400',
|
||||
blue: 'bg-accent-500/20 text-accent-400',
|
||||
orange: 'bg-warning-500/20 text-warning-400',
|
||||
red: 'bg-error-500/20 text-error-400',
|
||||
purple: 'bg-accent-500/20 text-accent-400',
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -265,8 +253,8 @@ function NodeCard({ node, onAction, isLoading }: NodeCardProps) {
|
||||
const statusColor = node.is_disabled
|
||||
? 'text-dark-500'
|
||||
: node.is_connected && node.is_node_online
|
||||
? 'text-emerald-400'
|
||||
: 'text-red-400';
|
||||
? 'text-success-400'
|
||||
: 'text-error-400';
|
||||
|
||||
const statusText = node.is_disabled
|
||||
? t('admin.remnawave.nodes.disabled', 'Disabled')
|
||||
@@ -313,8 +301,8 @@ function NodeCard({ node, onAction, isLoading }: NodeCardProps) {
|
||||
disabled={isLoading}
|
||||
className={`rounded-lg p-2 transition-colors disabled:opacity-50 ${
|
||||
node.is_disabled
|
||||
? 'bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30'
|
||||
: 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
|
||||
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
|
||||
: 'bg-error-500/20 text-error-400 hover:bg-error-500/30'
|
||||
}`}
|
||||
title={
|
||||
node.is_disabled
|
||||
@@ -351,11 +339,11 @@ function SquadCard({ squad, onSelect }: SquadCardProps) {
|
||||
{squad.display_name || squad.name}
|
||||
</h3>
|
||||
{squad.is_synced ? (
|
||||
<span className="rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs text-emerald-400">
|
||||
<span className="rounded-full bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
|
||||
{t('admin.remnawave.squads.synced', 'Synced')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-orange-500/20 px-2 py-0.5 text-xs text-orange-400">
|
||||
<span className="rounded-full bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
|
||||
{t('admin.remnawave.squads.notSynced', 'Not synced')}
|
||||
</span>
|
||||
)}
|
||||
@@ -374,7 +362,7 @@ function SquadCard({ squad, onSelect }: SquadCardProps) {
|
||||
)}
|
||||
<span>{squad.inbounds_count} inbounds</span>
|
||||
{squad.is_available !== undefined && (
|
||||
<span className={squad.is_available ? 'text-emerald-400' : 'text-red-400'}>
|
||||
<span className={squad.is_available ? 'text-success-400' : 'text-error-400'}>
|
||||
{squad.is_available ? '✓ Available' : '✗ Unavailable'}
|
||||
</span>
|
||||
)}
|
||||
@@ -412,7 +400,7 @@ function SyncCard({ title, description, onAction, isLoading, lastResult }: SyncC
|
||||
<p className="mt-1 text-xs text-dark-400">{description}</p>
|
||||
{lastResult && (
|
||||
<p
|
||||
className={`mt-2 text-xs ${lastResult.success ? 'text-emerald-400' : 'text-red-400'}`}
|
||||
className={`mt-2 text-xs ${lastResult.success ? 'text-success-400' : 'text-error-400'}`}
|
||||
>
|
||||
{lastResult.message}
|
||||
</p>
|
||||
@@ -678,7 +666,7 @@ function NodesTab({
|
||||
<button
|
||||
onClick={onRestartAll}
|
||||
disabled={isActionLoading}
|
||||
className="flex items-center gap-2 rounded-lg bg-orange-500/20 px-3 py-1.5 text-orange-400 transition-colors hover:bg-orange-500/30 disabled:opacity-50"
|
||||
className="flex items-center gap-2 rounded-lg bg-warning-500/20 px-3 py-1.5 text-warning-400 transition-colors hover:bg-warning-500/30 disabled:opacity-50"
|
||||
>
|
||||
<ArrowPathIcon />
|
||||
{t('admin.remnawave.nodes.restartAll', 'Restart All')}
|
||||
@@ -830,7 +818,7 @@ function SyncTab({
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs ${
|
||||
autoSyncStatus.enabled
|
||||
? 'bg-emerald-500/20 text-emerald-400'
|
||||
? 'bg-success-500/20 text-success-400'
|
||||
: 'bg-dark-600 text-dark-400'
|
||||
}`}
|
||||
>
|
||||
@@ -864,9 +852,9 @@ function SyncTab({
|
||||
<p
|
||||
className={
|
||||
autoSyncStatus.is_running
|
||||
? 'text-orange-400'
|
||||
? 'text-warning-400'
|
||||
: autoSyncStatus.last_run_success
|
||||
? 'text-emerald-400'
|
||||
? 'text-success-400'
|
||||
: 'text-dark-200'
|
||||
}
|
||||
>
|
||||
@@ -1104,14 +1092,9 @@ export default function AdminRemnawave() {
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
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"
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</Link>
|
||||
<div className="rounded-lg bg-purple-500/20 p-2">
|
||||
<ServerIcon className="h-6 w-6 text-purple-400" />
|
||||
<AdminBackButton />
|
||||
<div className="rounded-lg bg-accent-500/20 p-2">
|
||||
<ServerIcon className="h-6 w-6 text-accent-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">
|
||||
@@ -1126,11 +1109,11 @@ export default function AdminRemnawave() {
|
||||
{/* Connection Status Badge */}
|
||||
<div
|
||||
className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs ${
|
||||
isConfigured ? 'bg-emerald-500/20 text-emerald-400' : 'bg-red-500/20 text-red-400'
|
||||
isConfigured ? 'bg-success-500/20 text-success-400' : 'bg-error-500/20 text-error-400'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${isConfigured ? 'bg-emerald-400' : 'bg-red-400'}`}
|
||||
className={`h-2 w-2 rounded-full ${isConfigured ? 'bg-success-400' : 'bg-error-400'}`}
|
||||
/>
|
||||
{isConfigured
|
||||
? t('admin.remnawave.connected', 'Connected')
|
||||
@@ -1140,8 +1123,8 @@ export default function AdminRemnawave() {
|
||||
|
||||
{/* Configuration Error */}
|
||||
{status?.configuration_error && (
|
||||
<div className="mb-4 rounded-xl border border-red-500/30 bg-red-500/10 p-4">
|
||||
<p className="text-sm text-red-400">{status.configuration_error}</p>
|
||||
<div className="mb-4 rounded-xl border border-error-500/30 bg-error-500/10 p-4">
|
||||
<p className="text-sm text-error-400">{status.configuration_error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1275,7 +1258,9 @@ export default function AdminRemnawave() {
|
||||
<div>
|
||||
<p className="text-dark-500">Available</p>
|
||||
<p
|
||||
className={selectedSquad.is_available ? 'text-emerald-400' : 'text-red-400'}
|
||||
className={
|
||||
selectedSquad.is_available ? 'text-success-400' : 'text-error-400'
|
||||
}
|
||||
>
|
||||
{selectedSquad.is_available ? 'Yes' : 'No'}
|
||||
</p>
|
||||
@@ -1284,7 +1269,7 @@ export default function AdminRemnawave() {
|
||||
<p className="text-dark-500">Trial Eligible</p>
|
||||
<p
|
||||
className={
|
||||
selectedSquad.is_trial_eligible ? 'text-emerald-400' : 'text-dark-400'
|
||||
selectedSquad.is_trial_eligible ? 'text-success-400' : 'text-dark-400'
|
||||
}
|
||||
>
|
||||
{selectedSquad.is_trial_eligible ? 'Yes' : 'No'}
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { serversApi, ServerListItem, ServerDetail, ServerUpdateRequest } from '../api/servers';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
// Icons
|
||||
const BackIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SyncIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
@@ -369,12 +358,7 @@ export default function AdminServers() {
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
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>
|
||||
<AdminBackButton />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">{t('admin.servers.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.servers.subtitle')}</p>
|
||||
|
||||
@@ -4,13 +4,13 @@ import { useTranslation } from 'react-i18next';
|
||||
import { adminSettingsApi, SettingDefinition } from '../api/adminSettings';
|
||||
import { themeColorsApi } from '../api/themeColors';
|
||||
import { useFavoriteSettings } from '../hooks/useFavoriteSettings';
|
||||
import { MenuIcon, MENU_SECTIONS, MenuItem, formatSettingKey } from '../components/admin';
|
||||
import { MENU_SECTIONS, MenuItem, formatSettingKey, AdminBackButton } from '../components/admin';
|
||||
import { AnalyticsTab } from '../components/admin/AnalyticsTab';
|
||||
import { BrandingTab } from '../components/admin/BrandingTab';
|
||||
import { ThemeTab } from '../components/admin/ThemeTab';
|
||||
import { FavoritesTab } from '../components/admin/FavoritesTab';
|
||||
import { SettingsTab } from '../components/admin/SettingsTab';
|
||||
import { SettingsSidebar } from '../components/admin/SettingsSidebar';
|
||||
import { SettingsMobileTabs } from '../components/admin/SettingsMobileTabs';
|
||||
import {
|
||||
SettingsSearch,
|
||||
SettingsSearchMobile,
|
||||
@@ -23,19 +23,13 @@ export default function AdminSettings() {
|
||||
// State
|
||||
const [activeSection, setActiveSection] = useState('branding');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
// Favorites hook
|
||||
const { favorites, toggleFavorite, isFavorite } = useFavoriteSettings();
|
||||
|
||||
// Scroll to top on mount and section change (fix for mobile webviews)
|
||||
// Scroll to top on section change
|
||||
useEffect(() => {
|
||||
// Use requestAnimationFrame for smoother scroll on mobile
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||
document.documentElement.scrollTop = 0;
|
||||
document.body.scrollTop = 0;
|
||||
});
|
||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||
}, [activeSection]);
|
||||
|
||||
// Queries
|
||||
@@ -173,57 +167,92 @@ export default function AdminSettings() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-safe flex min-h-screen">
|
||||
{/* Mobile overlay */}
|
||||
{mobileMenuOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
<>
|
||||
{/* Mobile Layout */}
|
||||
<div className="space-y-4 lg:hidden">
|
||||
<SettingsMobileTabs
|
||||
activeSection={activeSection}
|
||||
setActiveSection={setActiveSection}
|
||||
favoritesCount={favorites.length}
|
||||
/>
|
||||
)}
|
||||
<SettingsSearchMobile searchQuery={searchQuery} setSearchQuery={setSearchQuery} />
|
||||
<SettingsSearchResults searchQuery={searchQuery} resultsCount={filteredSettings.length} />
|
||||
{renderContent()}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<SettingsSidebar
|
||||
activeSection={activeSection}
|
||||
setActiveSection={setActiveSection}
|
||||
mobileMenuOpen={mobileMenuOpen}
|
||||
setMobileMenuOpen={setMobileMenuOpen}
|
||||
favoritesCount={favorites.length}
|
||||
/>
|
||||
{/* Desktop Layout - fixed sidebar, scrollable content */}
|
||||
<div className="hidden h-[calc(100vh-120px)] lg:flex">
|
||||
{/* Fixed Sidebar */}
|
||||
<div className="w-64 shrink-0 overflow-y-auto border-r border-dark-700/50">
|
||||
<div className="border-b border-dark-700/50 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<AdminBackButton />
|
||||
<h1 className="text-lg font-bold text-dark-100">{t('admin.settings.title')}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="space-y-1 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)}
|
||||
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 && (
|
||||
<svg
|
||||
className={`h-4 w-4 ${isActive ? 'fill-current' : ''}`}
|
||||
fill={isActive ? 'currentColor' : 'none'}
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<span className="font-medium">{t(`admin.settings.${item.id}`)}</span>
|
||||
{item.id === 'favorites' && favorites.length > 0 && (
|
||||
<span className="ml-auto rounded-full bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
|
||||
{favorites.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="min-w-0 flex-1">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-30 border-b border-dark-700/50 bg-dark-900/95 p-3 backdrop-blur-xl sm:p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
className="rounded-xl bg-dark-800 p-2 transition-colors hover:bg-dark-700 lg:hidden"
|
||||
>
|
||||
<MenuIcon />
|
||||
</button>
|
||||
|
||||
<h2 className="truncate text-lg font-semibold text-dark-100 sm:text-xl">
|
||||
{/* Scrollable Content */}
|
||||
<div className="min-w-0 flex-1 overflow-y-auto p-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<h2 className="truncate text-xl font-semibold text-dark-100">
|
||||
{t(`admin.settings.${activeSection}`)}
|
||||
</h2>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<SettingsSearch
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
resultsCount={filteredSettings.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingsSearchMobile searchQuery={searchQuery} setSearchQuery={setSearchQuery} />
|
||||
|
||||
<SettingsSearchResults searchQuery={searchQuery} resultsCount={filteredSettings.length} />
|
||||
{renderContent()}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-3 sm:p-4 lg:p-6">{renderContent()}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
tariffsApi,
|
||||
TariffListItem,
|
||||
@@ -11,19 +10,9 @@ import {
|
||||
PeriodPrice,
|
||||
ServerInfo,
|
||||
} from '../api/tariffs';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
// Icons
|
||||
const BackIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PlusIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
@@ -130,7 +119,7 @@ function TariffTypeSelect({ onSelect, onClose }: TariffTypeSelectProps) {
|
||||
className="group w-full rounded-xl bg-dark-700 p-4 text-left transition-colors hover:bg-dark-600"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="rounded-lg bg-amber-500/20 p-3 text-amber-400 group-hover:bg-amber-500/30">
|
||||
<div className="rounded-lg bg-warning-500/20 p-3 text-warning-400 group-hover:bg-warning-500/30">
|
||||
<SunIcon />
|
||||
</div>
|
||||
<div>
|
||||
@@ -962,7 +951,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-dark-700 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-amber-500/20 p-2 text-amber-400">
|
||||
<div className="rounded-lg bg-warning-500/20 p-2 text-warning-400">
|
||||
<SunIcon />
|
||||
</div>
|
||||
<div>
|
||||
@@ -985,7 +974,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === tab
|
||||
? 'border-b-2 border-amber-400 text-amber-400'
|
||||
? 'border-b-2 border-warning-400 text-warning-400'
|
||||
: 'text-dark-400 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
@@ -1009,7 +998,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-amber-500 focus:outline-none"
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
placeholder={t('admin.tariffs.nameExampleDaily')}
|
||||
/>
|
||||
</div>
|
||||
@@ -1022,15 +1011,15 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full resize-none rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-amber-500 focus:outline-none"
|
||||
className="w-full resize-none rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
rows={2}
|
||||
placeholder={t('admin.tariffs.descriptionPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Daily Price */}
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-4">
|
||||
<label className="mb-2 block text-sm font-medium text-amber-400">
|
||||
<div className="rounded-lg border border-warning-500/30 bg-warning-500/10 p-4">
|
||||
<label className="mb-2 block text-sm font-medium text-warning-400">
|
||||
{t('admin.tariffs.dailyPriceLabel')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1040,7 +1029,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
onChange={(e) =>
|
||||
setDailyPriceKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)
|
||||
}
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-amber-500 focus:outline-none"
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={0}
|
||||
step={0.1}
|
||||
/>
|
||||
@@ -1061,7 +1050,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
type="number"
|
||||
value={trafficLimitGb}
|
||||
onChange={(e) => setTrafficLimitGb(Math.max(0, parseInt(e.target.value) || 0))}
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-amber-500 focus:outline-none"
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={0}
|
||||
/>
|
||||
<span className="text-dark-400">{t('admin.tariffs.gbUnit')}</span>
|
||||
@@ -1083,7 +1072,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
type="number"
|
||||
value={deviceLimit}
|
||||
onChange={(e) => setDeviceLimit(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-amber-500 focus:outline-none"
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
@@ -1099,7 +1088,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
onChange={(e) =>
|
||||
setTierLevel(Math.min(10, Math.max(1, parseInt(e.target.value) || 1)))
|
||||
}
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-amber-500 focus:outline-none"
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={1}
|
||||
max={10}
|
||||
/>
|
||||
@@ -1123,14 +1112,14 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
onClick={() => toggleServer(server.squad_uuid)}
|
||||
className={`cursor-pointer rounded-lg p-3 transition-colors ${
|
||||
isSelected
|
||||
? 'border border-amber-500/50 bg-amber-500/20'
|
||||
? 'border border-warning-500/50 bg-warning-500/20'
|
||||
: 'border border-transparent bg-dark-700 hover:bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex h-5 w-5 items-center justify-center rounded ${
|
||||
isSelected ? 'bg-amber-500 text-white' : 'bg-dark-600'
|
||||
isSelected ? 'bg-accent-500 text-white' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
{isSelected && <CheckIcon />}
|
||||
@@ -1165,7 +1154,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
onChange={(e) =>
|
||||
setDevicePriceKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)
|
||||
}
|
||||
className="w-24 rounded-lg border border-dark-500 bg-dark-600 px-3 py-2 text-dark-100 focus:border-amber-500 focus:outline-none"
|
||||
className="w-24 rounded-lg border border-dark-500 bg-dark-600 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={0}
|
||||
step={1}
|
||||
/>
|
||||
@@ -1182,7 +1171,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
onChange={(e) =>
|
||||
setMaxDeviceLimit(Math.max(0, parseInt(e.target.value) || 0))
|
||||
}
|
||||
className="w-24 rounded-lg border border-dark-500 bg-dark-600 px-3 py-2 text-dark-100 focus:border-amber-500 focus:outline-none"
|
||||
className="w-24 rounded-lg border border-dark-500 bg-dark-600 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
@@ -1200,7 +1189,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
type="button"
|
||||
onClick={() => setTrafficTopupEnabled(!trafficTopupEnabled)}
|
||||
className={`relative h-6 w-10 rounded-full transition-colors ${
|
||||
trafficTopupEnabled ? 'bg-amber-500' : 'bg-dark-600'
|
||||
trafficTopupEnabled ? 'bg-warning-500' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
@@ -1222,7 +1211,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
onChange={(e) =>
|
||||
setMaxTopupTrafficGb(Math.max(0, parseInt(e.target.value) || 0))
|
||||
}
|
||||
className="w-24 rounded-lg border border-dark-500 bg-dark-600 px-3 py-2 text-dark-100 focus:border-amber-500 focus:outline-none"
|
||||
className="w-24 rounded-lg border border-dark-500 bg-dark-600 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={0}
|
||||
/>
|
||||
<span className="text-dark-400">{t('admin.tariffs.gbUnit')}</span>
|
||||
@@ -1250,7 +1239,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
[String(gb)]: price,
|
||||
}));
|
||||
}}
|
||||
className="w-20 rounded border border-dark-500 bg-dark-600 px-2 py-1 text-sm text-dark-100 focus:border-amber-500 focus:outline-none"
|
||||
className="w-20 rounded border border-dark-500 bg-dark-600 px-2 py-1 text-sm text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={0}
|
||||
step={1}
|
||||
/>
|
||||
@@ -1310,7 +1299,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
onClick={() => setTrafficResetMode(option.value)}
|
||||
className={`w-full rounded-lg p-3 text-left transition-colors ${
|
||||
trafficResetMode === option.value
|
||||
? 'border border-amber-500 bg-amber-500/20'
|
||||
? 'border border-warning-500 bg-warning-500/20'
|
||||
: 'border border-dark-500 bg-dark-600 hover:border-dark-400'
|
||||
}`}
|
||||
>
|
||||
@@ -1322,7 +1311,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
<p className="mt-0.5 text-xs text-dark-400">{t(option.descKey)}</p>
|
||||
</div>
|
||||
{trafficResetMode === option.value && (
|
||||
<span className="text-amber-400">
|
||||
<span className="text-warning-400">
|
||||
<CheckIcon />
|
||||
</span>
|
||||
)}
|
||||
@@ -1346,7 +1335,7 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!name || dailyPriceKopeks <= 0 || isLoading}
|
||||
className="rounded-lg bg-amber-500 px-4 py-2 text-white transition-colors hover:bg-amber-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? t('admin.tariffs.savingButton') : t('admin.tariffs.saveButton')}
|
||||
</button>
|
||||
@@ -1465,12 +1454,7 @@ export default function AdminTariffs() {
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
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>
|
||||
<AdminBackButton />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">{t('admin.tariffs.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.tariffs.subtitle')}</p>
|
||||
@@ -1511,7 +1495,7 @@ export default function AdminTariffs() {
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<h3 className="truncate font-medium text-dark-100">{tariff.name}</h3>
|
||||
{tariff.is_daily ? (
|
||||
<span className="rounded bg-amber-500/20 px-2 py-0.5 text-xs text-amber-400">
|
||||
<span className="rounded bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
|
||||
{t('admin.tariffs.dailyType')}
|
||||
</span>
|
||||
) : (
|
||||
@@ -1532,7 +1516,7 @@ export default function AdminTariffs() {
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
|
||||
{tariff.is_daily && tariff.daily_price_kopeks > 0 && (
|
||||
<span className="text-amber-400">
|
||||
<span className="text-warning-400">
|
||||
{(tariff.daily_price_kopeks / 100).toFixed(2)}{' '}
|
||||
{t('admin.tariffs.currencyPerDay')}
|
||||
</span>
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { adminApi, AdminTicket, AdminTicketDetail, AdminTicketMessage } from '../api/admin';
|
||||
import { ticketsApi } from '../api/tickets';
|
||||
|
||||
const BackIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
function AdminMessageMedia({
|
||||
message,
|
||||
@@ -220,12 +208,7 @@ export default function AdminTickets() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
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>
|
||||
<AdminBackButton />
|
||||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">
|
||||
{t('admin.tickets.title')}
|
||||
</h1>
|
||||
|
||||
849
src/pages/AdminUserDetail.tsx
Normal file
849
src/pages/AdminUserDetail.tsx
Normal file
@@ -0,0 +1,849 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '../i18n';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import {
|
||||
adminUsersApi,
|
||||
type UserDetailResponse,
|
||||
type UserAvailableTariff,
|
||||
type PanelSyncStatusResponse,
|
||||
type UpdateSubscriptionRequest,
|
||||
} from '../api/adminUsers';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
// ============ Icons ============
|
||||
|
||||
const RefreshIcon = ({ className = 'w-5 h-5' }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PlusIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MinusIcon = () => (
|
||||
<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 12h-15" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SyncIcon = ({ className = 'w-5 h-5' }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const TelegramIcon = () => (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ============ Components ============
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const styles: Record<string, string> = {
|
||||
active: 'bg-success-500/20 text-success-400 border-success-500/30',
|
||||
blocked: 'bg-error-500/20 text-error-400 border-error-500/30',
|
||||
deleted: 'bg-dark-600 text-dark-400 border-dark-500',
|
||||
trial: 'bg-accent-500/20 text-accent-400 border-accent-500/30',
|
||||
expired: 'bg-warning-500/20 text-warning-400 border-warning-500/30',
|
||||
disabled: 'bg-dark-600 text-dark-400 border-dark-500',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`rounded-full border px-2 py-0.5 text-xs ${styles[status] || styles.active}`}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Main Page ============
|
||||
|
||||
export default function AdminUserDetail() {
|
||||
const { t } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' };
|
||||
const locale = localeMap[i18n.language] || 'ru-RU';
|
||||
|
||||
const [user, setUser] = useState<UserDetailResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<'info' | 'subscription' | 'balance' | 'sync'>('info');
|
||||
const [syncStatus, setSyncStatus] = useState<PanelSyncStatusResponse | null>(null);
|
||||
const [tariffs, setTariffs] = useState<UserAvailableTariff[]>([]);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
// Balance form
|
||||
const [balanceAmount, setBalanceAmount] = useState('');
|
||||
const [balanceDescription, setBalanceDescription] = useState('');
|
||||
|
||||
// Subscription form
|
||||
const [subAction, setSubAction] = useState<string>('extend');
|
||||
const [subDays, setSubDays] = useState('30');
|
||||
const [selectedTariffId, setSelectedTariffId] = useState<number | null>(null);
|
||||
|
||||
const userId = id ? parseInt(id, 10) : null;
|
||||
|
||||
const loadUser = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await adminUsersApi.getUser(userId);
|
||||
setUser(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load user:', error);
|
||||
navigate('/admin/users');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId, navigate]);
|
||||
|
||||
const loadSyncStatus = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
try {
|
||||
const data = await adminUsersApi.getSyncStatus(userId);
|
||||
setSyncStatus(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load sync status:', error);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
const loadTariffs = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
try {
|
||||
const data = await adminUsersApi.getAvailableTariffs(userId, true);
|
||||
setTariffs(data.tariffs);
|
||||
} catch (error) {
|
||||
console.error('Failed to load tariffs:', error);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId || isNaN(userId)) {
|
||||
navigate('/admin/users');
|
||||
return;
|
||||
}
|
||||
loadUser();
|
||||
}, [userId, loadUser, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'sync') loadSyncStatus();
|
||||
if (activeTab === 'subscription') loadTariffs();
|
||||
}, [activeTab, loadSyncStatus, loadTariffs]);
|
||||
|
||||
const handleUpdateBalance = async (isAdd: boolean) => {
|
||||
if (!balanceAmount || !userId) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const amount = Math.abs(parseFloat(balanceAmount) * 100);
|
||||
await adminUsersApi.updateBalance(userId, {
|
||||
amount_kopeks: isAdd ? amount : -amount,
|
||||
description:
|
||||
balanceDescription ||
|
||||
(isAdd
|
||||
? t('admin.users.detail.balance.addByAdmin')
|
||||
: t('admin.users.detail.balance.subtractByAdmin')),
|
||||
});
|
||||
await loadUser();
|
||||
setBalanceAmount('');
|
||||
setBalanceDescription('');
|
||||
} catch (error) {
|
||||
console.error('Failed to update balance:', error);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateSubscription = async () => {
|
||||
if (!userId) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const data: UpdateSubscriptionRequest = {
|
||||
action: subAction as UpdateSubscriptionRequest['action'],
|
||||
...(subAction === 'extend' ? { days: parseInt(subDays) } : {}),
|
||||
...(subAction === 'change_tariff' && selectedTariffId
|
||||
? { tariff_id: selectedTariffId }
|
||||
: {}),
|
||||
...(subAction === 'create'
|
||||
? {
|
||||
days: parseInt(subDays),
|
||||
...(selectedTariffId ? { tariff_id: selectedTariffId } : {}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
await adminUsersApi.updateSubscription(userId, data);
|
||||
await loadUser();
|
||||
} catch (error) {
|
||||
console.error('Failed to update subscription:', error);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlockUser = async () => {
|
||||
if (!userId || !confirm(t('admin.users.confirm.block'))) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await adminUsersApi.blockUser(userId);
|
||||
await loadUser();
|
||||
} catch (error) {
|
||||
console.error('Failed to block user:', error);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnblockUser = async () => {
|
||||
if (!userId) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await adminUsersApi.unblockUser(userId);
|
||||
await loadUser();
|
||||
} catch (error) {
|
||||
console.error('Failed to unblock user:', error);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncFromPanel = async () => {
|
||||
if (!userId) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await adminUsersApi.syncFromPanel(userId, {
|
||||
update_subscription: true,
|
||||
update_traffic: true,
|
||||
});
|
||||
await loadUser();
|
||||
await loadSyncStatus();
|
||||
} catch (error) {
|
||||
console.error('Failed to sync from panel:', error);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncToPanel = async () => {
|
||||
if (!userId) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await adminUsersApi.syncToPanel(userId, { create_if_missing: true });
|
||||
await loadUser();
|
||||
await loadSyncStatus();
|
||||
} catch (error) {
|
||||
console.error('Failed to sync to panel:', error);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: string | null) => {
|
||||
if (!date) return '-';
|
||||
return new Date(date).toLocaleDateString(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4">
|
||||
<p className="text-dark-400">{t('admin.users.notFound')}</p>
|
||||
<button
|
||||
onClick={() => navigate('/admin/users')}
|
||||
className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
{t('common.back')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<AdminBackButton to="/admin/users" />
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-gradient-to-br from-accent-500 to-accent-700 text-lg font-bold text-white">
|
||||
{user.first_name?.[0] || user.username?.[0] || '?'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-dark-100">{user.full_name}</div>
|
||||
<div className="flex items-center gap-2 text-sm text-dark-400">
|
||||
<TelegramIcon />
|
||||
{user.telegram_id}
|
||||
{user.username && <span>@{user.username}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={loadUser} className="rounded-lg p-2 transition-colors hover:bg-dark-700">
|
||||
<RefreshIcon className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 flex rounded-xl border border-dark-700 bg-dark-800/50">
|
||||
{(['info', 'subscription', 'balance', 'sync'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`flex-1 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === tab
|
||||
? 'border-b-2 border-accent-400 text-accent-400'
|
||||
: 'text-dark-400 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
{tab === 'info' && t('admin.users.detail.tabs.info')}
|
||||
{tab === 'subscription' && t('admin.users.detail.tabs.subscription')}
|
||||
{tab === 'balance' && t('admin.users.detail.tabs.balance')}
|
||||
{tab === 'sync' && t('admin.users.detail.tabs.sync')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-4">
|
||||
{/* Info Tab */}
|
||||
{activeTab === 'info' && (
|
||||
<div className="space-y-4">
|
||||
{/* Status */}
|
||||
<div className="flex items-center justify-between rounded-xl bg-dark-800/50 p-3">
|
||||
<span className="text-dark-400">{t('admin.users.detail.status')}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={user.status} />
|
||||
{user.status === 'active' ? (
|
||||
<button
|
||||
onClick={handleBlockUser}
|
||||
disabled={actionLoading}
|
||||
className="rounded-lg bg-error-500/20 px-3 py-1 text-xs text-error-400 transition-colors hover:bg-error-500/30"
|
||||
>
|
||||
{t('admin.users.actions.block')}
|
||||
</button>
|
||||
) : user.status === 'blocked' ? (
|
||||
<button
|
||||
onClick={handleUnblockUser}
|
||||
disabled={actionLoading}
|
||||
className="rounded-lg bg-success-500/20 px-3 py-1 text-xs text-success-400 transition-colors hover:bg-success-500/30"
|
||||
>
|
||||
{t('admin.users.actions.unblock')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-xl bg-dark-800/50 p-3">
|
||||
<div className="mb-1 text-xs text-dark-500">Email</div>
|
||||
<div className="text-dark-100">{user.email || '-'}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-800/50 p-3">
|
||||
<div className="mb-1 text-xs text-dark-500">{t('admin.users.detail.language')}</div>
|
||||
<div className="text-dark-100">{user.language}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-800/50 p-3">
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('admin.users.detail.registration')}
|
||||
</div>
|
||||
<div className="text-dark-100">{formatDate(user.created_at)}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-800/50 p-3">
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('admin.users.detail.lastActivity')}
|
||||
</div>
|
||||
<div className="text-dark-100">{formatDate(user.last_activity)}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-800/50 p-3">
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('admin.users.detail.totalSpent')}
|
||||
</div>
|
||||
<div className="text-dark-100">
|
||||
{formatWithCurrency(user.total_spent_kopeks / 100)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-800/50 p-3">
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('admin.users.detail.purchases')}
|
||||
</div>
|
||||
<div className="text-dark-100">{user.purchase_count}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Referral */}
|
||||
<div className="rounded-xl bg-dark-800/50 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-dark-200">
|
||||
{t('admin.users.detail.referral.title')}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-bold text-dark-100">
|
||||
{user.referral.referrals_count}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.referral.referrals')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-dark-100">
|
||||
{formatWithCurrency(user.referral.total_earnings_kopeks / 100)}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.referral.earned')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-dark-100">
|
||||
{user.referral.commission_percent || 0}%
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.referral.commission')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Restrictions */}
|
||||
{(user.restriction_topup || user.restriction_subscription) && (
|
||||
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-error-400">
|
||||
{t('admin.users.detail.restrictions.title')}
|
||||
</div>
|
||||
{user.restriction_topup && (
|
||||
<div className="text-xs text-error-300">
|
||||
{t('admin.users.detail.restrictions.topup')}
|
||||
</div>
|
||||
)}
|
||||
{user.restriction_subscription && (
|
||||
<div className="text-xs text-error-300">
|
||||
{t('admin.users.detail.restrictions.subscription')}
|
||||
</div>
|
||||
)}
|
||||
{user.restriction_reason && (
|
||||
<div className="mt-1 text-xs text-dark-400">
|
||||
{t('admin.users.detail.restrictions.reason')}: {user.restriction_reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subscription Tab */}
|
||||
{activeTab === 'subscription' && (
|
||||
<div className="space-y-4">
|
||||
{user.subscription ? (
|
||||
<>
|
||||
{/* Current subscription */}
|
||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="font-medium text-dark-200">
|
||||
{t('admin.users.detail.subscription.current')}
|
||||
</span>
|
||||
<StatusBadge status={user.subscription.status} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.subscription.tariff')}
|
||||
</div>
|
||||
<div className="text-dark-100">
|
||||
{user.subscription.tariff_name ||
|
||||
t('admin.users.detail.subscription.notSpecified')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.subscription.validUntil')}
|
||||
</div>
|
||||
<div className="text-dark-100">{formatDate(user.subscription.end_date)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.subscription.traffic')}
|
||||
</div>
|
||||
<div className="text-dark-100">
|
||||
{user.subscription.traffic_used_gb.toFixed(1)} /{' '}
|
||||
{user.subscription.traffic_limit_gb} {t('common.units.gb')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.subscription.devices')}
|
||||
</div>
|
||||
<div className="text-dark-100">{user.subscription.device_limit}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||
<div className="mb-3 font-medium text-dark-200">
|
||||
{t('admin.users.detail.subscription.actions')}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
value={subAction}
|
||||
onChange={(e) => setSubAction(e.target.value)}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
>
|
||||
<option value="extend">{t('admin.users.detail.subscription.extend')}</option>
|
||||
<option value="change_tariff">
|
||||
{t('admin.users.detail.subscription.changeTariff')}
|
||||
</option>
|
||||
<option value="cancel">{t('admin.users.detail.subscription.cancel')}</option>
|
||||
<option value="activate">
|
||||
{t('admin.users.detail.subscription.activate')}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
{subAction === 'extend' && (
|
||||
<input
|
||||
type="number"
|
||||
value={subDays}
|
||||
onChange={(e) => setSubDays(e.target.value)}
|
||||
placeholder={t('admin.users.detail.subscription.days')}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
/>
|
||||
)}
|
||||
|
||||
{subAction === 'change_tariff' && (
|
||||
<select
|
||||
value={selectedTariffId || ''}
|
||||
onChange={(e) =>
|
||||
setSelectedTariffId(e.target.value ? parseInt(e.target.value) : null)
|
||||
}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
>
|
||||
<option value="">
|
||||
{t('admin.users.detail.subscription.selectTariff')}
|
||||
</option>
|
||||
{tariffs.map((tariffItem) => (
|
||||
<option key={tariffItem.id} value={tariffItem.id}>
|
||||
{tariffItem.name}{' '}
|
||||
{!tariffItem.is_available &&
|
||||
t('admin.users.detail.subscription.unavailable')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleUpdateSubscription}
|
||||
disabled={actionLoading}
|
||||
className="w-full rounded-lg bg-accent-500 py-2 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading
|
||||
? t('admin.users.actions.applying')
|
||||
: t('admin.users.actions.apply')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||
<div className="mb-4 text-center text-dark-400">
|
||||
{t('admin.users.detail.subscription.noActive')}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
value={selectedTariffId || ''}
|
||||
onChange={(e) =>
|
||||
setSelectedTariffId(e.target.value ? parseInt(e.target.value) : null)
|
||||
}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
>
|
||||
<option value="">{t('admin.users.detail.subscription.selectTariff')}</option>
|
||||
{tariffs.map((tariffItem) => (
|
||||
<option key={tariffItem.id} value={tariffItem.id}>
|
||||
{tariffItem.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
value={subDays}
|
||||
onChange={(e) => setSubDays(e.target.value)}
|
||||
placeholder={t('admin.users.detail.subscription.days')}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSubAction('create');
|
||||
handleUpdateSubscription();
|
||||
}}
|
||||
disabled={actionLoading}
|
||||
className="w-full rounded-lg bg-success-500 py-2 text-white transition-colors hover:bg-success-600 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading
|
||||
? t('admin.users.detail.subscription.creating')
|
||||
: t('admin.users.detail.subscription.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Balance Tab */}
|
||||
{activeTab === 'balance' && (
|
||||
<div className="space-y-4">
|
||||
{/* Current balance */}
|
||||
<div className="rounded-xl border border-accent-500/30 bg-gradient-to-r from-accent-500/20 to-accent-700/20 p-4">
|
||||
<div className="mb-1 text-sm text-dark-400">
|
||||
{t('admin.users.detail.balance.current')}
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-dark-100">
|
||||
{formatWithCurrency(user.balance_rubles)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add/subtract form */}
|
||||
<div className="space-y-3 rounded-xl bg-dark-800/50 p-4">
|
||||
<input
|
||||
type="number"
|
||||
value={balanceAmount}
|
||||
onChange={(e) => setBalanceAmount(e.target.value)}
|
||||
placeholder={t('admin.users.detail.balance.amountPlaceholder')}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={balanceDescription}
|
||||
onChange={(e) => setBalanceDescription(e.target.value)}
|
||||
placeholder={t('admin.users.detail.balance.descriptionPlaceholder')}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleUpdateBalance(true)}
|
||||
disabled={actionLoading || !balanceAmount}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-success-500 py-2 text-white transition-colors hover:bg-success-600 disabled:opacity-50"
|
||||
>
|
||||
<PlusIcon /> {t('admin.users.detail.balance.add')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUpdateBalance(false)}
|
||||
disabled={actionLoading || !balanceAmount}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-error-500 py-2 text-white transition-colors hover:bg-error-600 disabled:opacity-50"
|
||||
>
|
||||
<MinusIcon /> {t('admin.users.detail.balance.subtract')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent transactions */}
|
||||
{user.recent_transactions.length > 0 && (
|
||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||
<div className="mb-3 font-medium text-dark-200">
|
||||
{t('admin.users.detail.balance.recentTransactions')}
|
||||
</div>
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto">
|
||||
{user.recent_transactions.map((tx) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="flex items-center justify-between border-b border-dark-700 py-2 last:border-0"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm text-dark-200">{tx.description || tx.type}</div>
|
||||
<div className="text-xs text-dark-500">{formatDate(tx.created_at)}</div>
|
||||
</div>
|
||||
<div
|
||||
className={tx.amount_kopeks >= 0 ? 'text-success-400' : 'text-error-400'}
|
||||
>
|
||||
{tx.amount_kopeks >= 0 ? '+' : ''}
|
||||
{formatWithCurrency(tx.amount_rubles)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sync Tab */}
|
||||
{activeTab === 'sync' && (
|
||||
<div className="space-y-4">
|
||||
{/* Sync status */}
|
||||
{syncStatus && (
|
||||
<div
|
||||
className={`rounded-xl border p-4 ${syncStatus.has_differences ? 'border-warning-500/30 bg-warning-500/10' : 'border-success-500/30 bg-success-500/10'}`}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
{syncStatus.has_differences ? (
|
||||
<span className="font-medium text-warning-400">
|
||||
{t('admin.users.detail.sync.hasDifferences')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-medium text-success-400">
|
||||
{t('admin.users.detail.sync.synced')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{syncStatus.differences.length > 0 && (
|
||||
<div className="mb-3 space-y-1">
|
||||
{syncStatus.differences.map((diff, i) => (
|
||||
<div key={i} className="text-xs text-dark-300">
|
||||
• {diff}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="mb-2 text-xs text-dark-500">
|
||||
{t('admin.users.detail.sync.bot')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.statusLabel')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.bot_subscription_status || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">{t('admin.users.detail.sync.until')}:</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.bot_subscription_end_date
|
||||
? new Date(syncStatus.bot_subscription_end_date).toLocaleDateString(
|
||||
locale,
|
||||
)
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.traffic')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.bot_traffic_used_gb.toFixed(2)} {t('common.units.gb')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.devices')}:
|
||||
</span>
|
||||
<span className="text-dark-200">{syncStatus.bot_device_limit}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.squads')}:
|
||||
</span>
|
||||
<span className="text-dark-200">{syncStatus.bot_squads?.length || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 text-xs text-dark-500">
|
||||
{t('admin.users.detail.sync.panel')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.statusLabel')}:
|
||||
</span>
|
||||
<span className="text-dark-200">{syncStatus.panel_status || '-'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">{t('admin.users.detail.sync.until')}:</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.panel_expire_at
|
||||
? new Date(syncStatus.panel_expire_at).toLocaleDateString(locale)
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.traffic')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.panel_traffic_used_gb.toFixed(2)} {t('common.units.gb')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.devices')}:
|
||||
</span>
|
||||
<span className="text-dark-200">{syncStatus.panel_device_limit}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.squads')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.panel_squads?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* UUID info */}
|
||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||
<div className="mb-1 text-sm text-dark-400">Remnawave UUID</div>
|
||||
<div className="break-all font-mono text-sm text-dark-100">
|
||||
{syncStatus?.remnawave_uuid ||
|
||||
user.remnawave_uuid ||
|
||||
t('admin.users.detail.sync.notLinked')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sync buttons */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSyncFromPanel}
|
||||
disabled={actionLoading}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-accent-500/30 bg-accent-500/20 py-3 text-accent-400 transition-colors hover:bg-accent-500/30 disabled:opacity-50"
|
||||
>
|
||||
<SyncIcon className={actionLoading ? 'animate-spin' : ''} />
|
||||
{t('admin.users.detail.sync.fromPanel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSyncToPanel}
|
||||
disabled={actionLoading}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-accent-500/30 bg-accent-500/20 py-3 text-accent-400 transition-colors hover:bg-accent-500/30 disabled:opacity-50"
|
||||
>
|
||||
<SyncIcon className={actionLoading ? 'animate-spin' : ''} />
|
||||
{t('admin.users.detail.sync.toPanel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,9 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '../i18n';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import {
|
||||
adminUsersApi,
|
||||
type UserListItem,
|
||||
type UserDetailResponse,
|
||||
type UsersStatsResponse,
|
||||
type UserAvailableTariff,
|
||||
type PanelSyncStatusResponse,
|
||||
type UpdateSubscriptionRequest,
|
||||
} from '../api/adminUsers';
|
||||
import { adminUsersApi, type UserListItem, type UsersStatsResponse } from '../api/adminUsers';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
// ============ Icons ============
|
||||
|
||||
@@ -37,12 +29,6 @@ const ChevronRightIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const XMarkIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const RefreshIcon = ({ className = 'w-5 h-5' }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
@@ -53,28 +39,6 @@ const RefreshIcon = ({ className = 'w-5 h-5' }: { className?: string }) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PlusIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MinusIcon = () => (
|
||||
<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 12h-15" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SyncIcon = ({ className = 'w-5 h-5' }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const TelegramIcon = () => (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
@@ -92,11 +56,11 @@ interface StatCardProps {
|
||||
|
||||
function StatCard({ title, value, subtitle, color }: StatCardProps) {
|
||||
const colors = {
|
||||
blue: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
green: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
|
||||
yellow: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||
red: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
|
||||
purple: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
|
||||
blue: 'bg-accent-500/20 text-accent-400 border-accent-500/30',
|
||||
green: 'bg-success-500/20 text-success-400 border-success-500/30',
|
||||
yellow: 'bg-warning-500/20 text-warning-400 border-warning-500/30',
|
||||
red: 'bg-error-500/20 text-error-400 border-error-500/30',
|
||||
purple: 'bg-accent-500/20 text-accent-400 border-accent-500/30',
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -110,11 +74,11 @@ function StatCard({ title, value, subtitle, color }: StatCardProps) {
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const styles: Record<string, string> = {
|
||||
active: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
|
||||
blocked: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
|
||||
active: 'bg-success-500/20 text-success-400 border-success-500/30',
|
||||
blocked: 'bg-error-500/20 text-error-400 border-error-500/30',
|
||||
deleted: 'bg-dark-600 text-dark-400 border-dark-500',
|
||||
trial: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
expired: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||
trial: 'bg-accent-500/20 text-accent-400 border-accent-500/30',
|
||||
expired: 'bg-warning-500/20 text-warning-400 border-warning-500/30',
|
||||
disabled: 'bg-dark-600 text-dark-400 border-dark-500',
|
||||
};
|
||||
|
||||
@@ -129,19 +93,19 @@ function StatusBadge({ status }: { status: string }) {
|
||||
|
||||
interface UserRowProps {
|
||||
user: UserListItem;
|
||||
onSelect: (user: UserListItem) => void;
|
||||
onClick: () => void;
|
||||
formatAmount: (rubAmount: number) => string;
|
||||
}
|
||||
|
||||
function UserRow({ user, onSelect, formatAmount }: UserRowProps) {
|
||||
function UserRow({ user, onClick, formatAmount }: UserRowProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
onClick={() => onSelect(user)}
|
||||
onClick={onClick}
|
||||
className="flex cursor-pointer items-center gap-4 rounded-xl border border-dark-700 bg-dark-800/50 p-4 transition-all hover:border-dark-600 hover:bg-dark-800"
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-blue-500 to-purple-600 font-medium text-white">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-accent-500 to-accent-700 font-medium text-white">
|
||||
{user.first_name?.[0] || user.username?.[0] || '?'}
|
||||
</div>
|
||||
|
||||
@@ -161,10 +125,10 @@ function UserRow({ user, onSelect, formatAmount }: UserRowProps) {
|
||||
<span
|
||||
className={`rounded-full border px-2 py-0.5 text-xs ${
|
||||
user.subscription_status === 'active'
|
||||
? 'border-emerald-500/30 bg-emerald-500/20 text-emerald-400'
|
||||
? 'border-success-500/30 bg-success-500/20 text-success-400'
|
||||
: user.subscription_status === 'trial'
|
||||
? 'border-blue-500/30 bg-blue-500/20 text-blue-400'
|
||||
: 'border-amber-500/30 bg-amber-500/20 text-amber-400'
|
||||
? 'border-accent-500/30 bg-accent-500/20 text-accent-400'
|
||||
: 'border-warning-500/30 bg-warning-500/20 text-warning-400'
|
||||
}`}
|
||||
>
|
||||
{user.subscription_status === 'active'
|
||||
@@ -192,787 +156,12 @@ function UserRow({ user, onSelect, formatAmount }: UserRowProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// ============ User Detail Modal ============
|
||||
|
||||
interface UserDetailModalProps {
|
||||
userId: number;
|
||||
onClose: () => void;
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' };
|
||||
const locale = localeMap[i18n.language] || 'ru-RU';
|
||||
const [user, setUser] = useState<UserDetailResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<'info' | 'subscription' | 'balance' | 'sync'>('info');
|
||||
const [syncStatus, setSyncStatus] = useState<PanelSyncStatusResponse | null>(null);
|
||||
const [tariffs, setTariffs] = useState<UserAvailableTariff[]>([]);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
// Balance form
|
||||
const [balanceAmount, setBalanceAmount] = useState('');
|
||||
const [balanceDescription, setBalanceDescription] = useState('');
|
||||
|
||||
// Subscription form
|
||||
const [subAction, setSubAction] = useState<string>('extend');
|
||||
const [subDays, setSubDays] = useState('30');
|
||||
const [selectedTariffId, setSelectedTariffId] = useState<number | null>(null);
|
||||
|
||||
const loadUser = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await adminUsersApi.getUser(userId);
|
||||
setUser(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load user:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
const loadSyncStatus = useCallback(async () => {
|
||||
try {
|
||||
const data = await adminUsersApi.getSyncStatus(userId);
|
||||
setSyncStatus(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load sync status:', error);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
const loadTariffs = useCallback(async () => {
|
||||
try {
|
||||
const data = await adminUsersApi.getAvailableTariffs(userId, true);
|
||||
setTariffs(data.tariffs);
|
||||
} catch (error) {
|
||||
console.error('Failed to load tariffs:', error);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadUser();
|
||||
}, [loadUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'sync') loadSyncStatus();
|
||||
if (activeTab === 'subscription') loadTariffs();
|
||||
}, [activeTab, loadSyncStatus, loadTariffs]);
|
||||
|
||||
const handleUpdateBalance = async (isAdd: boolean) => {
|
||||
if (!balanceAmount) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const amount = Math.abs(parseFloat(balanceAmount) * 100);
|
||||
await adminUsersApi.updateBalance(userId, {
|
||||
amount_kopeks: isAdd ? amount : -amount,
|
||||
description:
|
||||
balanceDescription ||
|
||||
(isAdd
|
||||
? t('admin.users.detail.balance.addByAdmin')
|
||||
: t('admin.users.detail.balance.subtractByAdmin')),
|
||||
});
|
||||
await loadUser();
|
||||
setBalanceAmount('');
|
||||
setBalanceDescription('');
|
||||
onUpdate();
|
||||
} catch (error) {
|
||||
console.error('Failed to update balance:', error);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateSubscription = async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const data: UpdateSubscriptionRequest = {
|
||||
action: subAction as UpdateSubscriptionRequest['action'],
|
||||
...(subAction === 'extend' ? { days: parseInt(subDays) } : {}),
|
||||
...(subAction === 'change_tariff' && selectedTariffId
|
||||
? { tariff_id: selectedTariffId }
|
||||
: {}),
|
||||
...(subAction === 'create'
|
||||
? {
|
||||
days: parseInt(subDays),
|
||||
...(selectedTariffId ? { tariff_id: selectedTariffId } : {}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
await adminUsersApi.updateSubscription(userId, data);
|
||||
await loadUser();
|
||||
onUpdate();
|
||||
} catch (error) {
|
||||
console.error('Failed to update subscription:', error);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlockUser = async () => {
|
||||
if (!confirm(t('admin.users.confirm.block'))) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await adminUsersApi.blockUser(userId);
|
||||
await loadUser();
|
||||
onUpdate();
|
||||
} catch (error) {
|
||||
console.error('Failed to block user:', error);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnblockUser = async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await adminUsersApi.unblockUser(userId);
|
||||
await loadUser();
|
||||
onUpdate();
|
||||
} catch (error) {
|
||||
console.error('Failed to unblock user:', error);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncFromPanel = async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await adminUsersApi.syncFromPanel(userId, {
|
||||
update_subscription: true,
|
||||
update_traffic: true,
|
||||
});
|
||||
await loadUser();
|
||||
await loadSyncStatus();
|
||||
onUpdate();
|
||||
} catch (error) {
|
||||
console.error('Failed to sync from panel:', error);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncToPanel = async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await adminUsersApi.syncToPanel(userId, { create_if_missing: true });
|
||||
await loadUser();
|
||||
await loadSyncStatus();
|
||||
onUpdate();
|
||||
} catch (error) {
|
||||
console.error('Failed to sync to panel:', error);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: string | null) => {
|
||||
if (!date) return '-';
|
||||
return new Date(date).toLocaleDateString(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm">
|
||||
<div className="rounded-2xl bg-dark-800 p-8">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl bg-dark-800">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-dark-700 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-gradient-to-br from-blue-500 to-purple-600 text-lg font-bold text-white">
|
||||
{user.first_name?.[0] || user.username?.[0] || '?'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-dark-100">{user.full_name}</div>
|
||||
<div className="flex items-center gap-2 text-sm text-dark-400">
|
||||
<TelegramIcon />
|
||||
{user.telegram_id}
|
||||
{user.username && <span>@{user.username}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded-lg p-2 transition-colors hover:bg-dark-700">
|
||||
<XMarkIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-dark-700">
|
||||
{(['info', 'subscription', 'balance', 'sync'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`flex-1 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === tab
|
||||
? 'border-b-2 border-blue-400 text-blue-400'
|
||||
: 'text-dark-400 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
{tab === 'info' && t('admin.users.detail.tabs.info')}
|
||||
{tab === 'subscription' && t('admin.users.detail.tabs.subscription')}
|
||||
{tab === 'balance' && t('admin.users.detail.tabs.balance')}
|
||||
{tab === 'sync' && t('admin.users.detail.tabs.sync')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{/* Info Tab */}
|
||||
{activeTab === 'info' && (
|
||||
<div className="space-y-4">
|
||||
{/* Status */}
|
||||
<div className="flex items-center justify-between rounded-xl bg-dark-900/50 p-3">
|
||||
<span className="text-dark-400">{t('admin.users.detail.status')}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={user.status} />
|
||||
{user.status === 'active' ? (
|
||||
<button
|
||||
onClick={handleBlockUser}
|
||||
disabled={actionLoading}
|
||||
className="rounded-lg bg-rose-500/20 px-3 py-1 text-xs text-rose-400 transition-colors hover:bg-rose-500/30"
|
||||
>
|
||||
{t('admin.users.actions.block')}
|
||||
</button>
|
||||
) : user.status === 'blocked' ? (
|
||||
<button
|
||||
onClick={handleUnblockUser}
|
||||
disabled={actionLoading}
|
||||
className="rounded-lg bg-emerald-500/20 px-3 py-1 text-xs text-emerald-400 transition-colors hover:bg-emerald-500/30"
|
||||
>
|
||||
{t('admin.users.actions.unblock')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-xl bg-dark-900/50 p-3">
|
||||
<div className="mb-1 text-xs text-dark-500">Email</div>
|
||||
<div className="text-dark-100">{user.email || '-'}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-900/50 p-3">
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('admin.users.detail.language')}
|
||||
</div>
|
||||
<div className="text-dark-100">{user.language}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-900/50 p-3">
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('admin.users.detail.registration')}
|
||||
</div>
|
||||
<div className="text-dark-100">{formatDate(user.created_at)}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-900/50 p-3">
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('admin.users.detail.lastActivity')}
|
||||
</div>
|
||||
<div className="text-dark-100">{formatDate(user.last_activity)}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-900/50 p-3">
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('admin.users.detail.totalSpent')}
|
||||
</div>
|
||||
<div className="text-dark-100">
|
||||
{formatWithCurrency(user.total_spent_kopeks / 100)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-900/50 p-3">
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('admin.users.detail.purchases')}
|
||||
</div>
|
||||
<div className="text-dark-100">{user.purchase_count}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Referral */}
|
||||
<div className="rounded-xl bg-dark-900/50 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-dark-200">
|
||||
{t('admin.users.detail.referral.title')}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-bold text-dark-100">
|
||||
{user.referral.referrals_count}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.referral.referrals')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-dark-100">
|
||||
{formatWithCurrency(user.referral.total_earnings_kopeks / 100)}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.referral.earned')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-dark-100">
|
||||
{user.referral.commission_percent || 0}%
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.referral.commission')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Restrictions */}
|
||||
{(user.restriction_topup || user.restriction_subscription) && (
|
||||
<div className="rounded-xl border border-rose-500/30 bg-rose-500/10 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-rose-400">
|
||||
{t('admin.users.detail.restrictions.title')}
|
||||
</div>
|
||||
{user.restriction_topup && (
|
||||
<div className="text-xs text-rose-300">
|
||||
{t('admin.users.detail.restrictions.topup')}
|
||||
</div>
|
||||
)}
|
||||
{user.restriction_subscription && (
|
||||
<div className="text-xs text-rose-300">
|
||||
{t('admin.users.detail.restrictions.subscription')}
|
||||
</div>
|
||||
)}
|
||||
{user.restriction_reason && (
|
||||
<div className="mt-1 text-xs text-dark-400">
|
||||
{t('admin.users.detail.restrictions.reason')}: {user.restriction_reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subscription Tab */}
|
||||
{activeTab === 'subscription' && (
|
||||
<div className="space-y-4">
|
||||
{user.subscription ? (
|
||||
<>
|
||||
{/* Current subscription */}
|
||||
<div className="rounded-xl bg-dark-900/50 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="font-medium text-dark-200">
|
||||
{t('admin.users.detail.subscription.current')}
|
||||
</span>
|
||||
<StatusBadge status={user.subscription.status} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.subscription.tariff')}
|
||||
</div>
|
||||
<div className="text-dark-100">
|
||||
{user.subscription.tariff_name ||
|
||||
t('admin.users.detail.subscription.notSpecified')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.subscription.validUntil')}
|
||||
</div>
|
||||
<div className="text-dark-100">
|
||||
{formatDate(user.subscription.end_date)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.subscription.traffic')}
|
||||
</div>
|
||||
<div className="text-dark-100">
|
||||
{user.subscription.traffic_used_gb.toFixed(1)} /{' '}
|
||||
{user.subscription.traffic_limit_gb} {t('common.units.gb')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.subscription.devices')}
|
||||
</div>
|
||||
<div className="text-dark-100">{user.subscription.device_limit}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="rounded-xl bg-dark-900/50 p-4">
|
||||
<div className="mb-3 font-medium text-dark-200">
|
||||
{t('admin.users.detail.subscription.actions')}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
value={subAction}
|
||||
onChange={(e) => setSubAction(e.target.value)}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
>
|
||||
<option value="extend">
|
||||
{t('admin.users.detail.subscription.extend')}
|
||||
</option>
|
||||
<option value="change_tariff">
|
||||
{t('admin.users.detail.subscription.changeTariff')}
|
||||
</option>
|
||||
<option value="cancel">
|
||||
{t('admin.users.detail.subscription.cancel')}
|
||||
</option>
|
||||
<option value="activate">
|
||||
{t('admin.users.detail.subscription.activate')}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
{subAction === 'extend' && (
|
||||
<input
|
||||
type="number"
|
||||
value={subDays}
|
||||
onChange={(e) => setSubDays(e.target.value)}
|
||||
placeholder={t('admin.users.detail.subscription.days')}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
/>
|
||||
)}
|
||||
|
||||
{subAction === 'change_tariff' && (
|
||||
<select
|
||||
value={selectedTariffId || ''}
|
||||
onChange={(e) =>
|
||||
setSelectedTariffId(e.target.value ? parseInt(e.target.value) : null)
|
||||
}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
>
|
||||
<option value="">
|
||||
{t('admin.users.detail.subscription.selectTariff')}
|
||||
</option>
|
||||
{tariffs.map((tariffItem) => (
|
||||
<option key={tariffItem.id} value={tariffItem.id}>
|
||||
{tariffItem.name}{' '}
|
||||
{!tariffItem.is_available &&
|
||||
t('admin.users.detail.subscription.unavailable')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleUpdateSubscription}
|
||||
disabled={actionLoading}
|
||||
className="w-full rounded-lg bg-blue-500 py-2 text-white transition-colors hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading
|
||||
? t('admin.users.actions.applying')
|
||||
: t('admin.users.actions.apply')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-xl bg-dark-900/50 p-4">
|
||||
<div className="mb-4 text-center text-dark-400">
|
||||
{t('admin.users.detail.subscription.noActive')}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
value={selectedTariffId || ''}
|
||||
onChange={(e) =>
|
||||
setSelectedTariffId(e.target.value ? parseInt(e.target.value) : null)
|
||||
}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
>
|
||||
<option value="">{t('admin.users.detail.subscription.selectTariff')}</option>
|
||||
{tariffs.map((tariffItem) => (
|
||||
<option key={tariffItem.id} value={tariffItem.id}>
|
||||
{tariffItem.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
value={subDays}
|
||||
onChange={(e) => setSubDays(e.target.value)}
|
||||
placeholder={t('admin.users.detail.subscription.days')}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSubAction('create');
|
||||
handleUpdateSubscription();
|
||||
}}
|
||||
disabled={actionLoading}
|
||||
className="w-full rounded-lg bg-emerald-500 py-2 text-white transition-colors hover:bg-emerald-600 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading
|
||||
? t('admin.users.detail.subscription.creating')
|
||||
: t('admin.users.detail.subscription.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Balance Tab */}
|
||||
{activeTab === 'balance' && (
|
||||
<div className="space-y-4">
|
||||
{/* Current balance */}
|
||||
<div className="rounded-xl border border-blue-500/30 bg-gradient-to-r from-blue-500/20 to-purple-500/20 p-4">
|
||||
<div className="mb-1 text-sm text-dark-400">
|
||||
{t('admin.users.detail.balance.current')}
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-dark-100">
|
||||
{formatWithCurrency(user.balance_rubles)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add/subtract form */}
|
||||
<div className="space-y-3 rounded-xl bg-dark-900/50 p-4">
|
||||
<input
|
||||
type="number"
|
||||
value={balanceAmount}
|
||||
onChange={(e) => setBalanceAmount(e.target.value)}
|
||||
placeholder={t('admin.users.detail.balance.amountPlaceholder')}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={balanceDescription}
|
||||
onChange={(e) => setBalanceDescription(e.target.value)}
|
||||
placeholder={t('admin.users.detail.balance.descriptionPlaceholder')}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleUpdateBalance(true)}
|
||||
disabled={actionLoading || !balanceAmount}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-emerald-500 py-2 text-white transition-colors hover:bg-emerald-600 disabled:opacity-50"
|
||||
>
|
||||
<PlusIcon /> {t('admin.users.detail.balance.add')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUpdateBalance(false)}
|
||||
disabled={actionLoading || !balanceAmount}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-rose-500 py-2 text-white transition-colors hover:bg-rose-600 disabled:opacity-50"
|
||||
>
|
||||
<MinusIcon /> {t('admin.users.detail.balance.subtract')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent transactions */}
|
||||
{user.recent_transactions.length > 0 && (
|
||||
<div className="rounded-xl bg-dark-900/50 p-4">
|
||||
<div className="mb-3 font-medium text-dark-200">
|
||||
{t('admin.users.detail.balance.recentTransactions')}
|
||||
</div>
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto">
|
||||
{user.recent_transactions.map((tx) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="flex items-center justify-between border-b border-dark-700 py-2 last:border-0"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm text-dark-200">{tx.description || tx.type}</div>
|
||||
<div className="text-xs text-dark-500">{formatDate(tx.created_at)}</div>
|
||||
</div>
|
||||
<div
|
||||
className={tx.amount_kopeks >= 0 ? 'text-emerald-400' : 'text-rose-400'}
|
||||
>
|
||||
{tx.amount_kopeks >= 0 ? '+' : ''}
|
||||
{formatWithCurrency(tx.amount_rubles)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sync Tab */}
|
||||
{activeTab === 'sync' && (
|
||||
<div className="space-y-4">
|
||||
{/* Sync status */}
|
||||
{syncStatus && (
|
||||
<div
|
||||
className={`rounded-xl border p-4 ${syncStatus.has_differences ? 'border-amber-500/30 bg-amber-500/10' : 'border-emerald-500/30 bg-emerald-500/10'}`}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
{syncStatus.has_differences ? (
|
||||
<span className="font-medium text-amber-400">
|
||||
{t('admin.users.detail.sync.hasDifferences')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-medium text-emerald-400">
|
||||
{t('admin.users.detail.sync.synced')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{syncStatus.differences.length > 0 && (
|
||||
<div className="mb-3 space-y-1">
|
||||
{syncStatus.differences.map((diff, i) => (
|
||||
<div key={i} className="text-xs text-dark-300">
|
||||
• {diff}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="mb-2 text-xs text-dark-500">
|
||||
{t('admin.users.detail.sync.bot')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.statusLabel')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.bot_subscription_status || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.until')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.bot_subscription_end_date
|
||||
? new Date(syncStatus.bot_subscription_end_date).toLocaleDateString(
|
||||
locale,
|
||||
)
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.traffic')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.bot_traffic_used_gb.toFixed(2)} {t('common.units.gb')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.devices')}:
|
||||
</span>
|
||||
<span className="text-dark-200">{syncStatus.bot_device_limit}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.squads')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.bot_squads?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 text-xs text-dark-500">
|
||||
{t('admin.users.detail.sync.panel')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.statusLabel')}:
|
||||
</span>
|
||||
<span className="text-dark-200">{syncStatus.panel_status || '-'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.until')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.panel_expire_at
|
||||
? new Date(syncStatus.panel_expire_at).toLocaleDateString(locale)
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.traffic')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.panel_traffic_used_gb.toFixed(2)} {t('common.units.gb')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.devices')}:
|
||||
</span>
|
||||
<span className="text-dark-200">{syncStatus.panel_device_limit}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.squads')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.panel_squads?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* UUID info */}
|
||||
<div className="rounded-xl bg-dark-900/50 p-4">
|
||||
<div className="mb-1 text-sm text-dark-400">Remnawave UUID</div>
|
||||
<div className="break-all font-mono text-sm text-dark-100">
|
||||
{syncStatus?.remnawave_uuid ||
|
||||
user.remnawave_uuid ||
|
||||
t('admin.users.detail.sync.notLinked')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sync buttons */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSyncFromPanel}
|
||||
disabled={actionLoading}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-blue-500/30 bg-blue-500/20 py-3 text-blue-400 transition-colors hover:bg-blue-500/30 disabled:opacity-50"
|
||||
>
|
||||
<SyncIcon className={actionLoading ? 'animate-spin' : ''} />
|
||||
{t('admin.users.detail.sync.fromPanel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSyncToPanel}
|
||||
disabled={actionLoading}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-purple-500/30 bg-purple-500/20 py-3 text-purple-400 transition-colors hover:bg-purple-500/30 disabled:opacity-50"
|
||||
>
|
||||
<SyncIcon className={actionLoading ? 'animate-spin' : ''} />
|
||||
{t('admin.users.detail.sync.toPanel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Main Page ============
|
||||
|
||||
export default function AdminUsers() {
|
||||
const { t } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [users, setUsers] = useState<UserListItem[]>([]);
|
||||
const [stats, setStats] = useState<UsersStatsResponse | null>(null);
|
||||
@@ -983,7 +172,6 @@ export default function AdminUsers() {
|
||||
const [sortBy, setSortBy] = useState<string>('created_at');
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
|
||||
|
||||
const limit = 20;
|
||||
|
||||
@@ -1038,12 +226,7 @@ export default function AdminUsers() {
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
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"
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</Link>
|
||||
<AdminBackButton />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-dark-100">{t('admin.users.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.users.subtitle')}</p>
|
||||
@@ -1155,7 +338,7 @@ export default function AdminUsers() {
|
||||
<div className="mb-4 space-y-2">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="py-12 text-center text-dark-400">{t('admin.users.noData')}</div>
|
||||
@@ -1164,7 +347,7 @@ export default function AdminUsers() {
|
||||
<UserRow
|
||||
key={user.id}
|
||||
user={user}
|
||||
onSelect={(u) => setSelectedUserId(u.id)}
|
||||
onClick={() => navigate(`/admin/users/${user.id}`)}
|
||||
formatAmount={(amount) => formatWithCurrency(amount)}
|
||||
/>
|
||||
))
|
||||
@@ -1202,18 +385,6 @@ export default function AdminUsers() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User detail modal */}
|
||||
{selectedUserId && (
|
||||
<UserDetailModal
|
||||
userId={selectedUserId}
|
||||
onClose={() => setSelectedUserId(null)}
|
||||
onUpdate={() => {
|
||||
loadUsers();
|
||||
loadStats();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { adminWheelApi, type WheelPrizeAdmin, type CreateWheelPrizeData } from '../api/wheel';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
// Icons
|
||||
const BackIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CogIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
@@ -143,18 +132,15 @@ export default function AdminWheel() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
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>
|
||||
<AdminBackButton />
|
||||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('admin.wheel.title')}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-sm ${
|
||||
config.is_enabled ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'
|
||||
config.is_enabled
|
||||
? 'bg-success-500/20 text-success-400'
|
||||
: 'bg-error-500/20 text-error-400'
|
||||
}`}
|
||||
>
|
||||
{config.is_enabled ? t('admin.wheel.enabled') : t('admin.wheel.disabled')}
|
||||
@@ -404,7 +390,7 @@ export default function AdminWheel() {
|
||||
deletePrizeMutation.mutate(prize.id);
|
||||
}
|
||||
}}
|
||||
className="btn-ghost text-red-400"
|
||||
className="btn-ghost text-error-400"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
@@ -432,13 +418,13 @@ export default function AdminWheel() {
|
||||
<div className="text-sm text-dark-400">{t('admin.wheel.statistics.totalSpins')}</div>
|
||||
</div>
|
||||
<div className="card p-4 text-center">
|
||||
<div className="text-3xl font-bold text-green-400">
|
||||
<div className="text-3xl font-bold text-success-400">
|
||||
{(stats.total_revenue_kopeks / 100).toFixed(0)}₽
|
||||
</div>
|
||||
<div className="text-sm text-dark-400">{t('admin.wheel.statistics.revenue')}</div>
|
||||
</div>
|
||||
<div className="card p-4 text-center">
|
||||
<div className="text-3xl font-bold text-yellow-400">
|
||||
<div className="text-3xl font-bold text-warning-400">
|
||||
{(stats.total_payout_kopeks / 100).toFixed(0)}₽
|
||||
</div>
|
||||
<div className="text-sm text-dark-400">{t('admin.wheel.statistics.payouts')}</div>
|
||||
@@ -447,8 +433,8 @@ export default function AdminWheel() {
|
||||
<div
|
||||
className={`text-3xl font-bold ${
|
||||
stats.actual_rtp_percent <= stats.configured_rtp_percent
|
||||
? 'text-green-400'
|
||||
: 'text-red-400'
|
||||
? 'text-success-400'
|
||||
: 'text-error-400'
|
||||
}`}
|
||||
>
|
||||
{stats.actual_rtp_percent.toFixed(1)}%
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { balanceApi } from '../api/balance';
|
||||
import TopUpModal from '../components/TopUpModal';
|
||||
@@ -9,6 +11,33 @@ import { useCurrency } from '../hooks/useCurrency';
|
||||
import { useToast } from '../components/Toast';
|
||||
import type { PaymentMethod, PaginatedResponse, Transaction } from '../types';
|
||||
|
||||
import { Card } from '@/components/data-display/Card';
|
||||
import { Button } from '@/components/primitives/Button';
|
||||
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
|
||||
|
||||
// Icons
|
||||
const ChevronDownIcon = ({ className = 'h-5 w-5' }: { className?: string }) => (
|
||||
<svg className={className} 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 WalletIcon = ({ className = 'h-8 w-8' }: { 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 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function Balance() {
|
||||
const { t } = useTranslation();
|
||||
const { refreshUser } = useAuthStore();
|
||||
@@ -23,7 +52,7 @@ export default function Balance() {
|
||||
const { data: balanceData, refetch: refetchBalance } = useQuery({
|
||||
queryKey: ['balance'],
|
||||
queryFn: balanceApi.getBalance,
|
||||
staleTime: 0, // Always refetch
|
||||
staleTime: 0,
|
||||
refetchOnMount: 'always',
|
||||
});
|
||||
|
||||
@@ -35,7 +64,6 @@ export default function Balance() {
|
||||
|
||||
// Handle payment return from payment gateway
|
||||
useEffect(() => {
|
||||
// Prevent duplicate handling in StrictMode
|
||||
if (paymentHandledRef.current) return;
|
||||
|
||||
const paymentStatus = searchParams.get('payment') || searchParams.get('status');
|
||||
@@ -48,14 +76,11 @@ export default function Balance() {
|
||||
if (isSuccess) {
|
||||
paymentHandledRef.current = true;
|
||||
|
||||
// Refetch balance and user data
|
||||
refetchBalance();
|
||||
refreshUser();
|
||||
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
||||
// Also invalidate subscription in case auto-activation happened
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||
|
||||
// Show success toast
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: t('balance.paymentSuccess.title'),
|
||||
@@ -63,10 +88,10 @@ export default function Balance() {
|
||||
duration: 6000,
|
||||
});
|
||||
|
||||
// Clean URL from query params
|
||||
navigate('/balance', { replace: true });
|
||||
}
|
||||
}, [searchParams, navigate, refetchBalance, refreshUser, queryClient, showToast, t]);
|
||||
|
||||
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod | null>(null);
|
||||
const [promocode, setPromocode] = useState('');
|
||||
const [promocodeLoading, setPromocodeLoading] = useState(false);
|
||||
@@ -138,7 +163,6 @@ export default function Balance() {
|
||||
});
|
||||
setTransactionsPage(1);
|
||||
setPromocode('');
|
||||
// Refresh balance and transactions
|
||||
await refetchBalance();
|
||||
await refreshUser();
|
||||
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
||||
@@ -146,7 +170,6 @@ export default function Balance() {
|
||||
} catch (error: unknown) {
|
||||
const axiosError = error as { response?: { data?: { detail?: string } } };
|
||||
const errorDetail = axiosError.response?.data?.detail || 'server_error';
|
||||
// Map backend error messages to translation keys
|
||||
const errorKey = errorDetail.toLowerCase().includes('not found')
|
||||
? 'not_found'
|
||||
: errorDetail.toLowerCase().includes('expired')
|
||||
@@ -163,213 +186,248 @@ export default function Balance() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('balance.title')}</h1>
|
||||
<motion.div
|
||||
className="space-y-6"
|
||||
variants={staggerContainer}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
>
|
||||
<motion.div variants={staggerItem}>
|
||||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('balance.title')}</h1>
|
||||
</motion.div>
|
||||
|
||||
{/* Balance Card */}
|
||||
<div className="bento-card bento-card-glow bg-gradient-to-br from-accent-500/10 to-transparent">
|
||||
<div className="mb-2 text-sm text-dark-400">{t('balance.currentBalance')}</div>
|
||||
<div className="text-4xl font-bold text-dark-50 sm:text-5xl">
|
||||
{formatAmount(balanceData?.balance_rubles || 0)}
|
||||
<span className="ml-2 text-2xl text-dark-400">{currencySymbol}</span>
|
||||
</div>
|
||||
</div>
|
||||
<motion.div variants={staggerItem}>
|
||||
<Card className="bg-gradient-to-br from-accent-500/10 to-transparent" glow>
|
||||
<div className="mb-2 text-sm text-dark-400">{t('balance.currentBalance')}</div>
|
||||
<div className="text-4xl font-bold text-dark-50 sm:text-5xl">
|
||||
{formatAmount(balanceData?.balance_rubles || 0)}
|
||||
<span className="ml-2 text-2xl text-dark-400">{currencySymbol}</span>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Promo Code Section */}
|
||||
<div className="bento-card">
|
||||
<h2 className="mb-4 text-lg font-semibold text-dark-100">{t('balance.promocode.title')}</h2>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={promocode}
|
||||
onChange={(e) => setPromocode(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handlePromocodeActivate()}
|
||||
placeholder={t('balance.promocode.placeholder')}
|
||||
className="input flex-1"
|
||||
disabled={promocodeLoading}
|
||||
/>
|
||||
<button
|
||||
onClick={handlePromocodeActivate}
|
||||
disabled={!promocode.trim() || promocodeLoading}
|
||||
className="btn-primary whitespace-nowrap px-6"
|
||||
>
|
||||
{promocodeLoading ? t('balance.promocode.activating') : t('balance.promocode.activate')}
|
||||
</button>
|
||||
</div>
|
||||
{promocodeError && (
|
||||
<div className="mt-3 rounded-lg border border-error-500/30 bg-error-500/10 p-3 text-sm text-error-400">
|
||||
{promocodeError}
|
||||
<motion.div variants={staggerItem}>
|
||||
<Card>
|
||||
<h2 className="mb-4 text-lg font-semibold text-dark-100">
|
||||
{t('balance.promocode.title')}
|
||||
</h2>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={promocode}
|
||||
onChange={(e) => setPromocode(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handlePromocodeActivate()}
|
||||
placeholder={t('balance.promocode.placeholder')}
|
||||
className="input flex-1"
|
||||
disabled={promocodeLoading}
|
||||
/>
|
||||
<Button
|
||||
onClick={handlePromocodeActivate}
|
||||
disabled={!promocode.trim()}
|
||||
loading={promocodeLoading}
|
||||
>
|
||||
{t('balance.promocode.activate')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{promocodeSuccess && (
|
||||
<div className="mt-3 rounded-lg border border-success-500/30 bg-success-500/10 p-3 text-sm text-success-400">
|
||||
<div className="font-medium">{promocodeSuccess.message}</div>
|
||||
{promocodeSuccess.amount > 0 && (
|
||||
<div className="mt-1">
|
||||
{t('balance.promocode.balanceAdded', {
|
||||
amount: promocodeSuccess.amount.toFixed(2),
|
||||
})}
|
||||
</div>
|
||||
<AnimatePresence mode="wait">
|
||||
{promocodeError && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="mt-3 rounded-linear border border-error-500/30 bg-error-500/10 p-3 text-sm text-error-400"
|
||||
>
|
||||
{promocodeError}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{promocodeSuccess && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="mt-3 rounded-linear border border-success-500/30 bg-success-500/10 p-3 text-sm text-success-400"
|
||||
>
|
||||
<div className="font-medium">{promocodeSuccess.message}</div>
|
||||
{promocodeSuccess.amount > 0 && (
|
||||
<div className="mt-1">
|
||||
{t('balance.promocode.balanceAdded', {
|
||||
amount: promocodeSuccess.amount.toFixed(2),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Payment Methods */}
|
||||
{paymentMethods && paymentMethods.length > 0 && (
|
||||
<div className="bento-card">
|
||||
<h2 className="mb-4 text-lg font-semibold text-dark-100">{t('balance.topUpBalance')}</h2>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{paymentMethods.map((method) => {
|
||||
const methodKey = method.id.toLowerCase().replace(/-/g, '_');
|
||||
const translatedName = t(`balance.paymentMethods.${methodKey}.name`, {
|
||||
defaultValue: '',
|
||||
});
|
||||
const translatedDesc = t(`balance.paymentMethods.${methodKey}.description`, {
|
||||
defaultValue: '',
|
||||
});
|
||||
<motion.div variants={staggerItem}>
|
||||
<Card>
|
||||
<h2 className="mb-4 text-lg font-semibold text-dark-100">
|
||||
{t('balance.topUpBalance')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{paymentMethods.map((method) => {
|
||||
const methodKey = method.id.toLowerCase().replace(/-/g, '_');
|
||||
const translatedName = t(`balance.paymentMethods.${methodKey}.name`, {
|
||||
defaultValue: '',
|
||||
});
|
||||
const translatedDesc = t(`balance.paymentMethods.${methodKey}.description`, {
|
||||
defaultValue: '',
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
key={method.id}
|
||||
disabled={!method.is_available}
|
||||
onClick={() => method.is_available && setSelectedMethod(method)}
|
||||
className={`bento-card-hover p-4 text-left transition-all ${
|
||||
method.is_available ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold text-dark-100">{translatedName || method.name}</div>
|
||||
{(translatedDesc || method.description) && (
|
||||
<div className="mt-1 text-sm text-dark-500">
|
||||
{translatedDesc || method.description}
|
||||
return (
|
||||
<Card
|
||||
key={method.id}
|
||||
interactive={method.is_available}
|
||||
className={!method.is_available ? 'cursor-not-allowed opacity-50' : ''}
|
||||
onClick={() => method.is_available && setSelectedMethod(method)}
|
||||
>
|
||||
<div className="font-semibold text-dark-100">
|
||||
{translatedName || method.name}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 text-xs text-dark-600">
|
||||
{formatAmount(method.min_amount_kopeks / 100, 0)} –{' '}
|
||||
{formatAmount(method.max_amount_kopeks / 100, 0)} {currencySymbol}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{(translatedDesc || method.description) && (
|
||||
<div className="mt-1 text-sm text-dark-500">
|
||||
{translatedDesc || method.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 text-xs text-dark-600">
|
||||
{formatAmount(method.min_amount_kopeks / 100, 0)} –{' '}
|
||||
{formatAmount(method.max_amount_kopeks / 100, 0)} {currencySymbol}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="bento-card overflow-hidden">
|
||||
<button
|
||||
onClick={() => setIsHistoryOpen(!isHistoryOpen)}
|
||||
className="flex w-full items-center justify-between text-left"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-dark-100">{t('balance.transactionHistory')}</h2>
|
||||
<svg
|
||||
className={`h-5 w-5 text-dark-400 transition-transform duration-200 ${isHistoryOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
{/* Transaction History */}
|
||||
<motion.div variants={staggerItem}>
|
||||
<Card className="overflow-hidden">
|
||||
<button
|
||||
onClick={() => setIsHistoryOpen(!isHistoryOpen)}
|
||||
className="flex w-full items-center justify-between text-left"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{t('balance.transactionHistory')}
|
||||
</h2>
|
||||
<ChevronDownIcon
|
||||
className={`h-5 w-5 text-dark-400 transition-transform duration-200 ${isHistoryOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isHistoryOpen && (
|
||||
<div className="mt-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
) : transactions?.items && transactions.items.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{transactions.items.map((tx) => {
|
||||
const isPositive = tx.amount_rubles >= 0;
|
||||
const displayAmount = Math.abs(tx.amount_rubles);
|
||||
const sign = isPositive ? '+' : '-';
|
||||
const colorClass = isPositive ? 'text-success-400' : 'text-error-400';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="flex items-center justify-between rounded-xl border border-dark-700/30 bg-dark-800/30 p-4"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="mb-1 flex items-center gap-3">
|
||||
<span className={getTypeBadge(tx.type)}>{getTypeLabel(tx.type)}</span>
|
||||
<span className="text-xs text-dark-500">
|
||||
{new Date(tx.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{tx.description && (
|
||||
<div className="text-sm text-dark-400">{tx.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`text-lg font-semibold ${colorClass}`}>
|
||||
{sign}
|
||||
{formatAmount(displayAmount)} {currencySymbol}
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{isHistoryOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="mt-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-12 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">
|
||||
<svg
|
||||
className="h-8 w-8 text-dark-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-dark-400">{t('balance.noTransactions')}</div>
|
||||
</div>
|
||||
)}
|
||||
) : transactions?.items && transactions.items.length > 0 ? (
|
||||
<motion.div
|
||||
className="space-y-3"
|
||||
variants={staggerContainer}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
>
|
||||
{transactions.items.map((tx) => {
|
||||
const isPositive = tx.amount_rubles >= 0;
|
||||
const displayAmount = Math.abs(tx.amount_rubles);
|
||||
const sign = isPositive ? '+' : '-';
|
||||
const colorClass = isPositive ? 'text-success-400' : 'text-error-400';
|
||||
|
||||
{transactions && transactions.pages > 1 && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3 text-sm text-dark-500">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTransactionsPage((prev) => Math.max(1, prev - 1))}
|
||||
disabled={transactions.page <= 1}
|
||||
className={`btn-secondary min-w-[120px] flex-1 text-xs sm:flex-none sm:text-sm ${
|
||||
transactions.page <= 1 ? 'cursor-not-allowed opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
{t('common.back')}
|
||||
</button>
|
||||
<div className="flex-1 text-center">
|
||||
{t('balance.page', { current: transactions.page, total: transactions.pages })}
|
||||
return (
|
||||
<motion.div
|
||||
key={tx.id}
|
||||
variants={staggerItem}
|
||||
className="flex items-center justify-between rounded-linear border border-dark-700/30 bg-dark-800/30 p-4"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="mb-1 flex items-center gap-3">
|
||||
<span className={getTypeBadge(tx.type)}>
|
||||
{getTypeLabel(tx.type)}
|
||||
</span>
|
||||
<span className="text-xs text-dark-500">
|
||||
{new Date(tx.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{tx.description && (
|
||||
<div className="text-sm text-dark-400">{tx.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`text-lg font-semibold ${colorClass}`}>
|
||||
{sign}
|
||||
{formatAmount(displayAmount)} {currencySymbol}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="py-12 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-linear-lg bg-dark-800">
|
||||
<WalletIcon className="h-8 w-8 text-dark-500" />
|
||||
</div>
|
||||
<div className="text-dark-400">{t('balance.noTransactions')}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{transactions && transactions.pages > 1 && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3 text-sm text-dark-500">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setTransactionsPage((prev) => Math.max(1, prev - 1))}
|
||||
disabled={transactions.page <= 1}
|
||||
className="min-w-[120px] flex-1 sm:flex-none"
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<div className="flex-1 text-center">
|
||||
{t('balance.page', {
|
||||
current: transactions.page,
|
||||
total: transactions.pages,
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setTransactionsPage((prev) =>
|
||||
transactions.pages ? Math.min(transactions.pages, prev + 1) : prev + 1,
|
||||
)
|
||||
}
|
||||
disabled={transactions.page >= transactions.pages}
|
||||
className="min-w-[120px] flex-1 sm:flex-none"
|
||||
>
|
||||
{t('common.next')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setTransactionsPage((prev) =>
|
||||
transactions.pages ? Math.min(transactions.pages, prev + 1) : prev + 1,
|
||||
)
|
||||
}
|
||||
disabled={transactions.page >= transactions.pages}
|
||||
className={`btn-secondary min-w-[120px] flex-1 text-xs sm:flex-none sm:text-sm ${
|
||||
transactions.page >= transactions.pages ? 'cursor-not-allowed opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
{t('common.next')}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* TopUp Modal */}
|
||||
{selectedMethod && (
|
||||
<TopUpModal method={selectedMethod} onClose={() => setSelectedMethod(null)} />
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { subscriptionApi } from '../api/subscription';
|
||||
import { referralApi } from '../api/referral';
|
||||
@@ -12,15 +14,25 @@ import Onboarding, { useOnboarding } from '../components/Onboarding';
|
||||
import PromoOffersSection from '../components/PromoOffersSection';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
|
||||
import { Card } from '@/components/data-display/Card';
|
||||
import { Button } from '@/components/primitives/Button';
|
||||
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
|
||||
|
||||
// Icons
|
||||
const ArrowRightIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
const ArrowRightIcon = ({ className = 'h-4 w-4' }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SparklesIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
const SparklesIcon = ({ className = 'h-5 w-5' }: { className?: string }) => (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -29,8 +41,8 @@ const SparklesIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ChevronRightIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
const ChevronRightIcon = ({ className = 'h-5 w-5' }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
);
|
||||
@@ -45,16 +57,6 @@ const RefreshIcon = ({ className = 'w-4 h-4' }: { className?: string }) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SupportLottieIcon = () => (
|
||||
<svg className="h-6 w-6" 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 default function Dashboard() {
|
||||
const { t } = useTranslation();
|
||||
const { user, refreshUser } = useAuthStore();
|
||||
@@ -244,13 +246,6 @@ export default function Dashboard() {
|
||||
});
|
||||
}
|
||||
|
||||
steps.push({
|
||||
target: 'quick-actions',
|
||||
title: t('onboarding.steps.quickActions.title'),
|
||||
description: t('onboarding.steps.quickActions.description'),
|
||||
placement: 'top',
|
||||
});
|
||||
|
||||
return steps;
|
||||
}, [t, subscription]);
|
||||
|
||||
@@ -267,372 +262,373 @@ export default function Dashboard() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<motion.div
|
||||
className="space-y-6"
|
||||
variants={staggerContainer}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
>
|
||||
{/* Header */}
|
||||
<div data-onboarding="welcome">
|
||||
<motion.div variants={staggerItem} data-onboarding="welcome">
|
||||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">
|
||||
{t('dashboard.welcome', { name: user?.first_name || user?.username || '' })}
|
||||
</h1>
|
||||
<p className="mt-1 text-dark-400">{t('dashboard.yourSubscription')}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Subscription Status - Main Card */}
|
||||
{subLoading ? (
|
||||
<div className="bento-card">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="skeleton h-6 w-24" />
|
||||
<div className="skeleton h-6 w-16 rounded-full" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-6 md:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i}>
|
||||
<div className="skeleton mb-2 h-4 w-20" />
|
||||
<div className="skeleton h-5 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<div className="skeleton h-2 w-full rounded-full" />
|
||||
</div>
|
||||
<div className="mt-6 grid grid-cols-2 gap-3">
|
||||
<div className="skeleton h-10 w-full rounded-xl" />
|
||||
<div className="skeleton h-10 w-full rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
) : subscription ? (
|
||||
<div className="bento-card">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-dark-100">{t('subscription.status')}</h2>
|
||||
<span className={subscription.is_active ? 'badge-success' : 'badge-error'}>
|
||||
{subscription.is_active ? t('subscription.active') : t('subscription.expired')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6 md:grid-cols-4">
|
||||
<div>
|
||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.expiresAt')}</div>
|
||||
<div className="font-medium text-dark-100">
|
||||
{new Date(subscription.end_date).toLocaleDateString()}
|
||||
</div>
|
||||
<motion.div variants={staggerItem}>
|
||||
<Card>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="skeleton h-6 w-24" />
|
||||
<div className="skeleton h-6 w-16 rounded-full" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="text-sm text-dark-500">{t('subscription.traffic')}</span>
|
||||
<button
|
||||
onClick={() => refreshTrafficMutation.mutate()}
|
||||
disabled={refreshTrafficMutation.isPending || trafficRefreshCooldown > 0}
|
||||
className="rounded-full p-1 text-dark-400 transition-colors hover:bg-dark-700/50 hover:text-accent-400 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title={
|
||||
trafficRefreshCooldown > 0 ? `${trafficRefreshCooldown}s` : t('common.refresh')
|
||||
}
|
||||
>
|
||||
<RefreshIcon
|
||||
className={`h-3.5 w-3.5 ${refreshTrafficMutation.isPending ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="font-medium text-dark-100">
|
||||
{(trafficData?.traffic_used_gb ?? subscription.traffic_used_gb).toFixed(1)} /{' '}
|
||||
{subscription.traffic_limit_gb || '∞'} GB
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-6 md:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i}>
|
||||
<div className="skeleton mb-2 h-4 w-20" />
|
||||
<div className="skeleton h-5 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.devices')}</div>
|
||||
<div className="font-medium text-dark-100">{subscription.device_limit}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.timeLeft')}</div>
|
||||
<div className="font-medium text-dark-100">
|
||||
{subscription.days_left > 0
|
||||
? t('subscription.days', { count: subscription.days_left })
|
||||
: `${t('subscription.hours', { count: subscription.hours_left })} ${t('subscription.minutes', { count: subscription.minutes_left })}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Traffic Progress */}
|
||||
{subscription.traffic_limit_gb > 0 && (
|
||||
<div className="mt-6">
|
||||
<div className="mb-2 flex justify-between text-sm">
|
||||
<span className="text-dark-400">{t('subscription.trafficUsed')}</span>
|
||||
<span className="text-dark-300">
|
||||
{(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent).toFixed(
|
||||
1,
|
||||
)}
|
||||
%
|
||||
</span>
|
||||
<div className="skeleton h-2 w-full rounded-full" />
|
||||
</div>
|
||||
<div className="mt-6 grid grid-cols-2 gap-3">
|
||||
<div className="skeleton h-10 w-full rounded-linear" />
|
||||
<div className="skeleton h-10 w-full rounded-linear" />
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
) : subscription ? (
|
||||
<motion.div variants={staggerItem}>
|
||||
<Card>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-dark-100">{t('subscription.status')}</h2>
|
||||
<span className={subscription.is_active ? 'badge-success' : 'badge-error'}>
|
||||
{subscription.is_active ? t('subscription.active') : t('subscription.expired')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6 md:grid-cols-4">
|
||||
<div>
|
||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.expiresAt')}</div>
|
||||
<div className="font-medium text-dark-100">
|
||||
{new Date(subscription.end_date).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className={`progress-fill ${getTrafficColor(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent)}`}
|
||||
style={{
|
||||
width: `${Math.min(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent, 100)}%`,
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="text-sm text-dark-500">{t('subscription.traffic')}</span>
|
||||
<button
|
||||
onClick={() => refreshTrafficMutation.mutate()}
|
||||
disabled={refreshTrafficMutation.isPending || trafficRefreshCooldown > 0}
|
||||
className="rounded-full p-1 text-dark-400 transition-colors hover:bg-dark-700/50 hover:text-accent-400 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title={
|
||||
trafficRefreshCooldown > 0
|
||||
? `${trafficRefreshCooldown}s`
|
||||
: t('common.refresh')
|
||||
}
|
||||
>
|
||||
<RefreshIcon
|
||||
className={`h-3.5 w-3.5 ${refreshTrafficMutation.isPending ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="font-medium text-dark-100">
|
||||
{(trafficData?.traffic_used_gb ?? subscription.traffic_used_gb).toFixed(1)} /{' '}
|
||||
{subscription.traffic_limit_gb || '∞'} GB
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.devices')}</div>
|
||||
<div className="font-medium text-dark-100">{subscription.device_limit}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.timeLeft')}</div>
|
||||
<div className="font-medium text-dark-100">
|
||||
{subscription.days_left > 0
|
||||
? t('subscription.days', { count: subscription.days_left })
|
||||
: `${t('subscription.hours', { count: subscription.hours_left })} ${t('subscription.minutes', { count: subscription.minutes_left })}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`mt-6 grid gap-3 ${subscription.subscription_url ? 'grid-cols-2' : 'grid-cols-1'}`}
|
||||
>
|
||||
<Link to="/subscription" className="btn-primary py-2.5 text-center text-sm">
|
||||
{t('dashboard.viewSubscription')}
|
||||
</Link>
|
||||
{subscription.subscription_url && (
|
||||
<button
|
||||
onClick={() => setShowConnectionModal(true)}
|
||||
className="btn-secondary py-2.5 text-sm"
|
||||
data-onboarding="connect-devices"
|
||||
>
|
||||
{t('subscription.getConfig')}
|
||||
</button>
|
||||
{/* Traffic Progress */}
|
||||
{subscription.traffic_limit_gb > 0 && (
|
||||
<div className="mt-6">
|
||||
<div className="mb-2 flex justify-between text-sm">
|
||||
<span className="text-dark-400">{t('subscription.trafficUsed')}</span>
|
||||
<span className="text-dark-300">
|
||||
{(
|
||||
trafficData?.traffic_used_percent ?? subscription.traffic_used_percent
|
||||
).toFixed(1)}
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className={`progress-fill ${getTrafficColor(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent)}`}
|
||||
style={{
|
||||
width: `${Math.min(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent, 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`mt-6 grid gap-3 ${subscription.subscription_url ? 'grid-cols-2' : 'grid-cols-1'}`}
|
||||
>
|
||||
<Button
|
||||
asChild
|
||||
variant="primary"
|
||||
size="lg"
|
||||
fullWidth
|
||||
className="text-center text-xs sm:text-sm"
|
||||
>
|
||||
<Link to="/subscription">{t('dashboard.viewSubscription')}</Link>
|
||||
</Button>
|
||||
{subscription.subscription_url && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
fullWidth
|
||||
className="text-center text-xs sm:text-sm"
|
||||
onClick={() => setShowConnectionModal(true)}
|
||||
data-onboarding="connect-devices"
|
||||
>
|
||||
{t('subscription.getConfig')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
) : null}
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="bento-grid">
|
||||
<motion.div variants={staggerItem} className="grid grid-cols-2 gap-3 lg:grid-cols-4 lg:gap-4">
|
||||
{/* Balance */}
|
||||
<Link to="/balance" className="bento-card-hover group" data-onboarding="balance">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm text-dark-400">{t('balance.currentBalance')}</span>
|
||||
<span className="text-dark-600 transition-colors group-hover:text-accent-400">
|
||||
<ArrowRightIcon />
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat-value text-accent-400">
|
||||
{formatAmount(balanceData?.balance_rubles || 0)}
|
||||
<span className="ml-1 text-lg text-dark-400">{currencySymbol}</span>
|
||||
</div>
|
||||
</Link>
|
||||
<Card interactive glow asChild>
|
||||
<Link to="/balance" data-onboarding="balance">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm text-dark-400">{t('balance.currentBalance')}</span>
|
||||
<ArrowRightIcon className="h-4 w-4 text-dark-600 transition-colors group-hover:text-accent-400" />
|
||||
</div>
|
||||
<div className="stat-value text-accent-400">
|
||||
{formatAmount(balanceData?.balance_rubles || 0)}
|
||||
<span className="ml-1 text-lg text-dark-400">{currencySymbol}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</Card>
|
||||
|
||||
{/* Subscription */}
|
||||
<Link
|
||||
to="/subscription"
|
||||
className="bento-card-hover group"
|
||||
data-onboarding="subscription-status"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm text-dark-400">{t('subscription.title')}</span>
|
||||
<span className="text-dark-600 transition-colors group-hover:text-accent-400">
|
||||
<ArrowRightIcon />
|
||||
</span>
|
||||
</div>
|
||||
{subLoading ? (
|
||||
<div className="skeleton h-8 w-24" />
|
||||
) : subscription ? (
|
||||
<div className="stat-value">
|
||||
{subscription.days_left > 0 ? (
|
||||
<>
|
||||
{subscription.days_left}
|
||||
<span className="ml-1 text-lg text-dark-400">{t('subscription.days')}</span>
|
||||
</>
|
||||
) : subscription.hours_left > 0 ? (
|
||||
<>
|
||||
{subscription.hours_left}
|
||||
<span className="ml-1 text-lg text-dark-400">{t('subscription.hours')}</span>
|
||||
</>
|
||||
) : subscription.minutes_left > 0 ? (
|
||||
<>
|
||||
{subscription.minutes_left}
|
||||
<span className="ml-1 text-lg text-dark-400">{t('subscription.minutes')}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-error-400">{t('subscription.expired')}</span>
|
||||
)}
|
||||
<Card interactive glow asChild>
|
||||
<Link to="/subscription" data-onboarding="subscription-status">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm text-dark-400">{t('subscription.title')}</span>
|
||||
<ArrowRightIcon className="h-4 w-4 text-dark-600 transition-colors group-hover:text-accent-400" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="stat-value text-error-400">{t('subscription.inactive')}</div>
|
||||
)}
|
||||
</Link>
|
||||
{subLoading ? (
|
||||
<div className="skeleton h-8 w-24" />
|
||||
) : subscription ? (
|
||||
<div className="stat-value">
|
||||
{subscription.days_left > 0 ? (
|
||||
<>
|
||||
{subscription.days_left}
|
||||
<span className="ml-1 text-lg text-dark-400">{t('subscription.days')}</span>
|
||||
</>
|
||||
) : subscription.hours_left > 0 ? (
|
||||
<>
|
||||
{subscription.hours_left}
|
||||
<span className="ml-1 text-lg text-dark-400">{t('subscription.hours')}</span>
|
||||
</>
|
||||
) : subscription.minutes_left > 0 ? (
|
||||
<>
|
||||
{subscription.minutes_left}
|
||||
<span className="ml-1 text-lg text-dark-400">{t('subscription.minutes')}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-error-400">{t('subscription.expired')}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="stat-value text-error-400">{t('subscription.inactive')}</div>
|
||||
)}
|
||||
</Link>
|
||||
</Card>
|
||||
|
||||
{/* Referrals */}
|
||||
<Link to="/referral" className="bento-card-hover group">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm text-dark-400">{t('referral.stats.totalReferrals')}</span>
|
||||
<span className="text-dark-600 transition-colors group-hover:text-accent-400">
|
||||
<ArrowRightIcon />
|
||||
</span>
|
||||
</div>
|
||||
{refLoading ? (
|
||||
<div className="skeleton h-8 w-16" />
|
||||
) : (
|
||||
<div className="stat-value">{referralInfo?.total_referrals || 0}</div>
|
||||
)}
|
||||
</Link>
|
||||
<Card interactive glow asChild>
|
||||
<Link to="/referral">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm text-dark-400">{t('referral.stats.totalReferrals')}</span>
|
||||
<ArrowRightIcon className="h-4 w-4 text-dark-600 transition-colors group-hover:text-accent-400" />
|
||||
</div>
|
||||
{refLoading ? (
|
||||
<div className="skeleton h-8 w-16" />
|
||||
) : (
|
||||
<div className="stat-value">{referralInfo?.total_referrals || 0}</div>
|
||||
)}
|
||||
</Link>
|
||||
</Card>
|
||||
|
||||
{/* Earnings */}
|
||||
<Link to="/referral" className="bento-card-hover group">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm text-dark-400">{t('referral.stats.totalEarnings')}</span>
|
||||
<span className="text-dark-600 transition-colors group-hover:text-accent-400">
|
||||
<ArrowRightIcon />
|
||||
</span>
|
||||
</div>
|
||||
{refLoading ? (
|
||||
<div className="skeleton h-8 w-20" />
|
||||
) : (
|
||||
<div className="stat-value text-success-400">
|
||||
{formatPositive(referralInfo?.total_earnings_rubles || 0)}
|
||||
<Card interactive glow asChild>
|
||||
<Link to="/referral">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm text-dark-400">{t('referral.stats.totalEarnings')}</span>
|
||||
<ArrowRightIcon className="h-4 w-4 text-dark-600 transition-colors group-hover:text-accent-400" />
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
{refLoading ? (
|
||||
<div className="skeleton h-8 w-20" />
|
||||
) : (
|
||||
<div className="stat-value text-success-400">
|
||||
{formatPositive(referralInfo?.total_earnings_rubles || 0)}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Trial Activation */}
|
||||
{hasNoSubscription && !trialLoading && trialInfo?.is_available && (
|
||||
<div className="bento-card-glow border-accent-500/30 bg-gradient-to-br from-accent-500/5 to-transparent">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-accent-500/20">
|
||||
<SparklesIcon />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="mb-2 text-lg font-semibold text-dark-100">
|
||||
{trialInfo.requires_payment
|
||||
? t('subscription.trial.titlePaid', 'Trial Subscription')
|
||||
: t('subscription.trial.title', 'Free Trial')}
|
||||
</h3>
|
||||
<p className="mb-4 text-sm text-dark-400">
|
||||
{t('subscription.trial.description', 'Try our VPN service for free!')}
|
||||
</p>
|
||||
|
||||
<div className="mb-6 flex gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-accent-400">
|
||||
{trialInfo.duration_days}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">{t('subscription.trial.days')}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-accent-400">
|
||||
{trialInfo.traffic_limit_gb || '∞'}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">GB</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-accent-400">{trialInfo.device_limit}</div>
|
||||
<div className="text-xs text-dark-500">{t('subscription.trial.devices')}</div>
|
||||
</div>
|
||||
<motion.div variants={staggerItem}>
|
||||
<Card
|
||||
className="border-accent-500/30 bg-gradient-to-br from-accent-500/5 to-transparent"
|
||||
glow
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-linear-lg bg-accent-500/20">
|
||||
<SparklesIcon className="h-6 w-6 text-accent-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="mb-2 text-lg font-semibold text-dark-100">
|
||||
{trialInfo.requires_payment
|
||||
? t('subscription.trial.titlePaid', 'Trial Subscription')
|
||||
: t('subscription.trial.title', 'Free Trial')}
|
||||
</h3>
|
||||
<p className="mb-4 text-sm text-dark-400">
|
||||
{t('subscription.trial.description', 'Try our VPN service for free!')}
|
||||
</p>
|
||||
|
||||
{trialInfo.requires_payment && trialInfo.price_rubles > 0 && (
|
||||
<div className="mb-4 space-y-2 rounded-xl bg-dark-800/50 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('subscription.trial.price', 'Activation price')}:
|
||||
</span>
|
||||
<span className="text-lg font-semibold text-accent-400">
|
||||
{trialInfo.price_rubles.toFixed(2)} {currencySymbol}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('balance.currentBalance', 'Your balance')}:
|
||||
</span>
|
||||
<span
|
||||
className={`text-lg font-semibold ${(balanceData?.balance_kopeks || 0) >= trialInfo.price_kopeks ? 'text-success-400' : 'text-warning-400'}`}
|
||||
>
|
||||
{formatAmount(balanceData?.balance_rubles || 0)} {currencySymbol}
|
||||
</span>
|
||||
</div>
|
||||
{(balanceData?.balance_kopeks || 0) < trialInfo.price_kopeks && (
|
||||
<div className="pt-1 text-xs text-warning-400">
|
||||
{t(
|
||||
'subscription.trial.insufficientBalance',
|
||||
'Top up your balance to activate',
|
||||
)}
|
||||
<div className="mb-6 flex gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-accent-400">
|
||||
{trialInfo.duration_days}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-dark-500">{t('subscription.trial.days')}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-accent-400">
|
||||
{trialInfo.traffic_limit_gb || '∞'}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">GB</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-accent-400">
|
||||
{trialInfo.device_limit}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">{t('subscription.trial.devices')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trialError && (
|
||||
<div className="mb-4 rounded-xl border border-error-500/30 bg-error-500/10 p-3 text-sm text-error-400">
|
||||
{trialError}
|
||||
</div>
|
||||
)}
|
||||
{trialInfo.requires_payment && trialInfo.price_rubles > 0 && (
|
||||
<div className="mb-4 space-y-2 rounded-linear-lg bg-dark-800/50 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('subscription.trial.price', 'Activation price')}:
|
||||
</span>
|
||||
<span className="text-lg font-semibold text-accent-400">
|
||||
{trialInfo.price_rubles.toFixed(2)} {currencySymbol}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-dark-400">
|
||||
{t('balance.currentBalance', 'Your balance')}:
|
||||
</span>
|
||||
<span
|
||||
className={`text-lg font-semibold ${(balanceData?.balance_kopeks || 0) >= trialInfo.price_kopeks ? 'text-success-400' : 'text-warning-400'}`}
|
||||
>
|
||||
{formatAmount(balanceData?.balance_rubles || 0)} {currencySymbol}
|
||||
</span>
|
||||
</div>
|
||||
{(balanceData?.balance_kopeks || 0) < trialInfo.price_kopeks && (
|
||||
<div className="pt-1 text-xs text-warning-400">
|
||||
{t(
|
||||
'subscription.trial.insufficientBalance',
|
||||
'Top up your balance to activate',
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trialInfo.requires_payment && trialInfo.price_kopeks > 0 ? (
|
||||
(balanceData?.balance_kopeks || 0) >= trialInfo.price_kopeks ? (
|
||||
<button
|
||||
onClick={() => activateTrialMutation.mutate()}
|
||||
disabled={activateTrialMutation.isPending}
|
||||
className="btn-primary w-full"
|
||||
>
|
||||
{activateTrialMutation.isPending
|
||||
? t('common.loading', 'Loading...')
|
||||
: t('subscription.trial.payAndActivate', 'Pay from Balance & Activate')}
|
||||
</button>
|
||||
{trialError && (
|
||||
<div className="mb-4 rounded-linear-lg border border-error-500/30 bg-error-500/10 p-3 text-sm text-error-400">
|
||||
{trialError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trialInfo.requires_payment && trialInfo.price_kopeks > 0 ? (
|
||||
(balanceData?.balance_kopeks || 0) >= trialInfo.price_kopeks ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
fullWidth
|
||||
onClick={() => activateTrialMutation.mutate()}
|
||||
loading={activateTrialMutation.isPending}
|
||||
>
|
||||
{t('subscription.trial.payAndActivate', 'Pay from Balance & Activate')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild variant="primary" fullWidth>
|
||||
<Link to="/balance">
|
||||
{t('subscription.trial.topUpToActivate', 'Top Up Balance')}
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<Link to="/balance" className="btn-primary block w-full text-center">
|
||||
{t('subscription.trial.topUpToActivate', 'Top Up Balance')}
|
||||
</Link>
|
||||
)
|
||||
) : (
|
||||
<button
|
||||
onClick={() => activateTrialMutation.mutate()}
|
||||
disabled={activateTrialMutation.isPending}
|
||||
className="btn-primary w-full"
|
||||
>
|
||||
{activateTrialMutation.isPending
|
||||
? t('common.loading', 'Loading...')
|
||||
: t('subscription.trial.activate', 'Activate Free Trial')}
|
||||
</button>
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
fullWidth
|
||||
onClick={() => activateTrialMutation.mutate()}
|
||||
loading={activateTrialMutation.isPending}
|
||||
>
|
||||
{t('subscription.trial.activate', 'Activate Free Trial')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Promo Offers */}
|
||||
<PromoOffersSection />
|
||||
<motion.div variants={staggerItem}>
|
||||
<PromoOffersSection />
|
||||
</motion.div>
|
||||
|
||||
{/* Fortune Wheel Banner */}
|
||||
{wheelConfig?.is_enabled && (
|
||||
<Link to="/wheel" className="bento-card-hover group flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Emoji */}
|
||||
<span className="text-3xl">🎰</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-base font-semibold text-dark-100">{t('wheel.banner.title')}</h3>
|
||||
<p className="text-sm text-dark-400">{t('wheel.banner.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-dark-500 transition-all duration-300 group-hover:translate-x-1 group-hover:text-accent-400">
|
||||
<ChevronRightIcon />
|
||||
</div>
|
||||
</Link>
|
||||
<motion.div variants={staggerItem}>
|
||||
<Card interactive asChild>
|
||||
<Link to="/wheel" className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-3xl">🎰</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-base font-semibold text-dark-100">
|
||||
{t('wheel.banner.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-dark-400">{t('wheel.banner.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRightIcon className="h-5 w-5 flex-shrink-0 text-dark-500 transition-all duration-300 group-hover:translate-x-1 group-hover:text-accent-400" />
|
||||
</Link>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="bento-card" data-onboarding="quick-actions">
|
||||
<h3 className="mb-4 text-lg font-semibold text-dark-100">{t('dashboard.quickActions')}</h3>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Link to="/balance" className="btn-secondary justify-center py-2.5 text-center text-sm">
|
||||
{t('dashboard.topUpBalance')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/subscription"
|
||||
state={{ scrollToExtend: true }}
|
||||
className="btn-secondary justify-center py-2.5 text-center text-sm"
|
||||
>
|
||||
{t('subscription.renew')}
|
||||
</Link>
|
||||
<Link to="/referral" className="btn-secondary justify-center py-2.5 text-center text-sm">
|
||||
{t('dashboard.inviteFriends')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/support"
|
||||
className="btn-secondary flex items-center justify-center gap-2 py-2.5 text-center text-sm"
|
||||
>
|
||||
<SupportLottieIcon />
|
||||
<span>{t('dashboard.getSupport')}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Modal */}
|
||||
{showConnectionModal && <ConnectionModal onClose={() => setShowConnectionModal(false)} />}
|
||||
|
||||
@@ -644,6 +640,6 @@ export default function Dashboard() {
|
||||
onSkip={handleOnboardingComplete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { authApi } from '../api/auth';
|
||||
import {
|
||||
@@ -12,6 +13,10 @@ import {
|
||||
import { referralApi } from '../api/referral';
|
||||
import { brandingApi, type EmailAuthEnabled } from '../api/branding';
|
||||
import ChangeEmailModal from '../components/ChangeEmailModal';
|
||||
import { Card } from '@/components/data-display/Card';
|
||||
import { Button } from '@/components/primitives/Button';
|
||||
import { Switch } from '@/components/primitives/Switch';
|
||||
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
|
||||
|
||||
// Icons
|
||||
const CopyIcon = () => (
|
||||
@@ -191,7 +196,6 @@ export default function Profile() {
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
// Валидация email
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!email.trim() || !emailRegex.test(email.trim())) {
|
||||
setError(t('profile.invalidEmail', 'Please enter a valid email address'));
|
||||
@@ -212,433 +216,376 @@ export default function Profile() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('profile.title')}</h1>
|
||||
<motion.div
|
||||
className="space-y-6"
|
||||
variants={staggerContainer}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
>
|
||||
<motion.div variants={staggerItem}>
|
||||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('profile.title')}</h1>
|
||||
</motion.div>
|
||||
|
||||
{/* User Info Card */}
|
||||
<div className="bento-card">
|
||||
<h2 className="mb-6 text-lg font-semibold text-dark-100">{t('profile.accountInfo')}</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-dark-800/50 py-3">
|
||||
<span className="text-dark-400">{t('profile.telegramId')}</span>
|
||||
<span className="font-medium text-dark-100">{user?.telegram_id}</span>
|
||||
</div>
|
||||
{user?.username && (
|
||||
<motion.div variants={staggerItem}>
|
||||
<Card>
|
||||
<h2 className="mb-6 text-lg font-semibold text-dark-100">{t('profile.accountInfo')}</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-dark-800/50 py-3">
|
||||
<span className="text-dark-400">{t('profile.username')}</span>
|
||||
<span className="font-medium text-dark-100">@{user.username}</span>
|
||||
<span className="text-dark-400">{t('profile.telegramId')}</span>
|
||||
<span className="font-medium text-dark-100">{user?.telegram_id}</span>
|
||||
</div>
|
||||
{user?.username && (
|
||||
<div className="flex items-center justify-between border-b border-dark-800/50 py-3">
|
||||
<span className="text-dark-400">{t('profile.username')}</span>
|
||||
<span className="font-medium text-dark-100">@{user.username}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between border-b border-dark-800/50 py-3">
|
||||
<span className="text-dark-400">{t('profile.name')}</span>
|
||||
<span className="font-medium text-dark-100">
|
||||
{user?.first_name} {user?.last_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<span className="text-dark-400">{t('profile.registeredAt')}</span>
|
||||
<span className="font-medium text-dark-100">
|
||||
{user?.created_at ? new Date(user.created_at).toLocaleDateString() : '-'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between border-b border-dark-800/50 py-3">
|
||||
<span className="text-dark-400">{t('profile.name')}</span>
|
||||
<span className="font-medium text-dark-100">
|
||||
{user?.first_name} {user?.last_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<span className="text-dark-400">{t('profile.registeredAt')}</span>
|
||||
<span className="font-medium text-dark-100">
|
||||
{user?.created_at ? new Date(user.created_at).toLocaleDateString() : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Referral Link Widget */}
|
||||
{referralTerms?.is_enabled && referralLink && (
|
||||
<div className="bento-card">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-dark-100">{t('referral.yourLink')}</h2>
|
||||
<Link
|
||||
to="/referral"
|
||||
className="flex items-center gap-1 text-accent-400 transition-colors hover:text-accent-300"
|
||||
>
|
||||
<span className="text-sm">{t('referral.title')}</span>
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<div className="flex-1">
|
||||
<input type="text" readOnly value={referralLink} className="input w-full text-sm" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={copyReferralLink}
|
||||
className={`btn-primary flex items-center gap-2 px-4 py-2 text-sm ${
|
||||
copied ? 'bg-success-500 hover:bg-success-500' : ''
|
||||
}`}
|
||||
<motion.div variants={staggerItem}>
|
||||
<Card>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-dark-100">{t('referral.yourLink')}</h2>
|
||||
<Link
|
||||
to="/referral"
|
||||
className="flex items-center gap-1 text-accent-400 transition-colors hover:text-accent-300"
|
||||
>
|
||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||
<span>{copied ? t('referral.copied') : t('referral.copyLink')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={shareReferralLink}
|
||||
className="btn-secondary flex items-center gap-2 px-4 py-2 text-sm"
|
||||
>
|
||||
<ShareIcon />
|
||||
<span className="hidden sm:inline">{t('referral.shareButton')}</span>
|
||||
</button>
|
||||
<span className="text-sm">{t('referral.title')}</span>
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-dark-500">
|
||||
{t('referral.shareHint', { percent: referralInfo?.commission_percent || 0 })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<div className="flex-1">
|
||||
<input type="text" readOnly value={referralLink} className="input w-full text-sm" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={copyReferralLink}
|
||||
variant={copied ? 'primary' : 'primary'}
|
||||
className={copied ? 'bg-success-500 hover:bg-success-500' : ''}
|
||||
>
|
||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||
<span className="ml-2">
|
||||
{copied ? t('referral.copied') : t('referral.copyLink')}
|
||||
</span>
|
||||
</Button>
|
||||
<Button onClick={shareReferralLink} variant="secondary">
|
||||
<ShareIcon />
|
||||
<span className="ml-2 hidden sm:inline">{t('referral.shareButton')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-dark-500">
|
||||
{t('referral.shareHint', { percent: referralInfo?.commission_percent || 0 })}
|
||||
</p>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Email Section - only show when email auth is enabled */}
|
||||
{isEmailAuthEnabled && (
|
||||
<div className="bento-card">
|
||||
<h2 className="mb-6 text-lg font-semibold text-dark-100">{t('profile.emailAuth')}</h2>
|
||||
<motion.div variants={staggerItem}>
|
||||
<Card>
|
||||
<h2 className="mb-6 text-lg font-semibold text-dark-100">{t('profile.emailAuth')}</h2>
|
||||
|
||||
{user?.email ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-dark-800/50 py-3">
|
||||
<span className="text-dark-400">Email</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium text-dark-100">{user.email}</span>
|
||||
{user.email_verified ? (
|
||||
<span className="badge-success">{t('profile.verified')}</span>
|
||||
) : (
|
||||
<span className="badge-warning">{t('profile.notVerified')}</span>
|
||||
)}
|
||||
{user?.email ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-dark-800/50 py-3">
|
||||
<span className="text-dark-400">Email</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium text-dark-100">{user.email}</span>
|
||||
{user.email_verified ? (
|
||||
<span className="badge-success">{t('profile.verified')}</span>
|
||||
) : (
|
||||
<span className="badge-warning">{t('profile.notVerified')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!user.email_verified && (
|
||||
<div className="rounded-linear border border-warning-500/30 bg-warning-500/10 p-4">
|
||||
<p className="mb-4 text-sm text-warning-400">
|
||||
{t('profile.verificationRequired')}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => resendVerificationMutation.mutate()}
|
||||
loading={resendVerificationMutation.isPending}
|
||||
>
|
||||
{t('profile.resendVerification')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user.email_verified && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-dark-400">{t('profile.canLoginWithEmail')}</p>
|
||||
<button
|
||||
onClick={() => setShowChangeEmailModal(true)}
|
||||
className="flex items-center gap-2 text-sm text-accent-400 transition-colors hover:text-accent-300"
|
||||
>
|
||||
<PencilIcon />
|
||||
<span>{t('profile.changeEmail.button')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="mb-6 text-sm text-dark-400">{t('profile.linkEmailDescription')}</p>
|
||||
|
||||
{!user.email_verified && (
|
||||
<div className="rounded-xl border border-warning-500/30 bg-warning-500/10 p-4">
|
||||
<p className="mb-4 text-sm text-warning-400">
|
||||
{t('profile.verificationRequired')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => resendVerificationMutation.mutate()}
|
||||
disabled={resendVerificationMutation.isPending}
|
||||
className="btn-primary"
|
||||
>
|
||||
{resendVerificationMutation.isPending
|
||||
? t('common.loading')
|
||||
: t('profile.resendVerification')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="label">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{user.email_verified && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-dark-400">{t('profile.canLoginWithEmail')}</p>
|
||||
<button
|
||||
onClick={() => setShowChangeEmailModal(true)}
|
||||
className="flex items-center gap-2 text-sm text-accent-400 transition-colors hover:text-accent-300"
|
||||
>
|
||||
<PencilIcon />
|
||||
<span>{t('profile.changeEmail.button')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="mb-6 text-sm text-dark-400">{t('profile.linkEmailDescription')}</p>
|
||||
<div>
|
||||
<label className="label">{t('auth.password')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t('profile.passwordPlaceholder')}
|
||||
className="input"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-dark-500">{t('profile.passwordHint')}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="label">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">{t('auth.confirmPassword')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder={t('profile.confirmPasswordPlaceholder')}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">{t('auth.password')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t('profile.passwordPlaceholder')}
|
||||
className="input"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-dark-500">{t('profile.passwordHint')}</p>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="rounded-linear border border-error-500/30 bg-error-500/10 p-4 text-sm text-error-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="label">{t('auth.confirmPassword')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder={t('profile.confirmPasswordPlaceholder')}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
{success && (
|
||||
<div className="rounded-linear border border-success-500/30 bg-success-500/10 p-4 text-sm text-success-400">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" fullWidth loading={registerEmailMutation.isPending}>
|
||||
{t('profile.linkEmail')}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(error || success) && user?.email && (
|
||||
<div className="mt-4">
|
||||
{error && (
|
||||
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-4 text-sm text-error-400">
|
||||
<div className="rounded-linear border border-error-500/30 bg-error-500/10 p-4 text-sm text-error-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="rounded-xl border border-success-500/30 bg-success-500/10 p-4 text-sm text-success-400">
|
||||
<div className="rounded-linear border border-success-500/30 bg-success-500/10 p-4 text-sm text-success-400">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={registerEmailMutation.isPending}
|
||||
className="btn-primary w-full"
|
||||
>
|
||||
{registerEmailMutation.isPending ? (
|
||||
<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('common.loading')}
|
||||
</span>
|
||||
) : (
|
||||
t('profile.linkEmail')
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(error || success) && user?.email && (
|
||||
<div className="mt-4">
|
||||
{error && (
|
||||
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-4 text-sm text-error-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="rounded-xl border border-success-500/30 bg-success-500/10 p-4 text-sm text-success-400">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Notification Settings */}
|
||||
<div className="bento-card">
|
||||
<h2 className="mb-6 text-lg font-semibold text-dark-100">
|
||||
{t('profile.notifications.title')}
|
||||
</h2>
|
||||
<motion.div variants={staggerItem}>
|
||||
<Card>
|
||||
<h2 className="mb-6 text-lg font-semibold text-dark-100">
|
||||
{t('profile.notifications.title')}
|
||||
</h2>
|
||||
|
||||
{notificationsLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
) : notificationSettings ? (
|
||||
<div className="space-y-6">
|
||||
{/* Subscription Expiry */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-dark-100">
|
||||
{t('profile.notifications.subscriptionExpiry')}
|
||||
</p>
|
||||
<p className="text-sm text-dark-400">
|
||||
{t('profile.notifications.subscriptionExpiryDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleNotificationToggle(
|
||||
'subscription_expiry_enabled',
|
||||
!notificationSettings.subscription_expiry_enabled,
|
||||
)
|
||||
}
|
||||
className={`relative h-6 w-12 rounded-full transition-colors ${
|
||||
notificationSettings.subscription_expiry_enabled
|
||||
? 'bg-accent-500'
|
||||
: 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||
notificationSettings.subscription_expiry_enabled ? 'left-7' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{notificationSettings.subscription_expiry_enabled && (
|
||||
<div className="flex items-center gap-3 pl-4">
|
||||
<span className="text-sm text-dark-400">
|
||||
{t('profile.notifications.daysBeforeExpiry')}
|
||||
</span>
|
||||
<select
|
||||
value={notificationSettings.subscription_expiry_days}
|
||||
onChange={(e) =>
|
||||
handleNotificationValue('subscription_expiry_days', Number(e.target.value))
|
||||
}
|
||||
className="input w-20 py-1"
|
||||
>
|
||||
{[1, 2, 3, 5, 7, 14].map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{d}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{notificationsLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Traffic Warning */}
|
||||
<div className="space-y-3 border-t border-dark-800/50 pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-dark-100">
|
||||
{t('profile.notifications.trafficWarning')}
|
||||
</p>
|
||||
<p className="text-sm text-dark-400">
|
||||
{t('profile.notifications.trafficWarningDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleNotificationToggle(
|
||||
'traffic_warning_enabled',
|
||||
!notificationSettings.traffic_warning_enabled,
|
||||
)
|
||||
}
|
||||
className={`relative h-6 w-12 rounded-full transition-colors ${
|
||||
notificationSettings.traffic_warning_enabled ? 'bg-accent-500' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||
notificationSettings.traffic_warning_enabled ? 'left-7' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{notificationSettings.traffic_warning_enabled && (
|
||||
<div className="flex items-center gap-3 pl-4">
|
||||
<span className="text-sm text-dark-400">
|
||||
{t('profile.notifications.atPercent')}
|
||||
</span>
|
||||
<select
|
||||
value={notificationSettings.traffic_warning_percent}
|
||||
onChange={(e) =>
|
||||
handleNotificationValue('traffic_warning_percent', Number(e.target.value))
|
||||
) : notificationSettings ? (
|
||||
<div className="space-y-6">
|
||||
{/* Subscription Expiry */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-dark-100">
|
||||
{t('profile.notifications.subscriptionExpiry')}
|
||||
</p>
|
||||
<p className="text-sm text-dark-400">
|
||||
{t('profile.notifications.subscriptionExpiryDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={notificationSettings.subscription_expiry_enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleNotificationToggle('subscription_expiry_enabled', checked)
|
||||
}
|
||||
className="input w-20 py-1"
|
||||
>
|
||||
{[50, 70, 80, 90, 95].map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}%
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Balance Low */}
|
||||
<div className="space-y-3 border-t border-dark-800/50 pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-dark-100">
|
||||
{t('profile.notifications.balanceLow')}
|
||||
</p>
|
||||
<p className="text-sm text-dark-400">
|
||||
{t('profile.notifications.balanceLowDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleNotificationToggle(
|
||||
'balance_low_enabled',
|
||||
!notificationSettings.balance_low_enabled,
|
||||
)
|
||||
}
|
||||
className={`relative h-6 w-12 rounded-full transition-colors ${
|
||||
notificationSettings.balance_low_enabled ? 'bg-accent-500' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||
notificationSettings.balance_low_enabled ? 'left-7' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{notificationSettings.subscription_expiry_enabled && (
|
||||
<div className="flex items-center gap-3 pl-4">
|
||||
<span className="text-sm text-dark-400">
|
||||
{t('profile.notifications.daysBeforeExpiry')}
|
||||
</span>
|
||||
<select
|
||||
value={notificationSettings.subscription_expiry_days}
|
||||
onChange={(e) =>
|
||||
handleNotificationValue('subscription_expiry_days', Number(e.target.value))
|
||||
}
|
||||
className="input w-20 py-1"
|
||||
>
|
||||
{[1, 2, 3, 5, 7, 14].map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{d}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{notificationSettings.balance_low_enabled && (
|
||||
<div className="flex items-center gap-3 pl-4">
|
||||
<span className="text-sm text-dark-400">
|
||||
{t('profile.notifications.threshold')}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={notificationSettings.balance_low_threshold}
|
||||
onChange={(e) =>
|
||||
handleNotificationValue('balance_low_threshold', Number(e.target.value))
|
||||
|
||||
{/* Traffic Warning */}
|
||||
<div className="space-y-3 border-t border-dark-800/50 pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-dark-100">
|
||||
{t('profile.notifications.trafficWarning')}
|
||||
</p>
|
||||
<p className="text-sm text-dark-400">
|
||||
{t('profile.notifications.trafficWarningDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={notificationSettings.traffic_warning_enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleNotificationToggle('traffic_warning_enabled', checked)
|
||||
}
|
||||
min={0}
|
||||
className="input w-24 py-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* News */}
|
||||
<div className="flex items-center justify-between border-t border-dark-800/50 pt-6">
|
||||
<div>
|
||||
<p className="font-medium text-dark-100">{t('profile.notifications.news')}</p>
|
||||
<p className="text-sm text-dark-400">{t('profile.notifications.newsDesc')}</p>
|
||||
{notificationSettings.traffic_warning_enabled && (
|
||||
<div className="flex items-center gap-3 pl-4">
|
||||
<span className="text-sm text-dark-400">
|
||||
{t('profile.notifications.atPercent')}
|
||||
</span>
|
||||
<select
|
||||
value={notificationSettings.traffic_warning_percent}
|
||||
onChange={(e) =>
|
||||
handleNotificationValue('traffic_warning_percent', Number(e.target.value))
|
||||
}
|
||||
className="input w-20 py-1"
|
||||
>
|
||||
{[50, 70, 80, 90, 95].map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}%
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleNotificationToggle('news_enabled', !notificationSettings.news_enabled)
|
||||
}
|
||||
className={`relative h-6 w-12 rounded-full transition-colors ${
|
||||
notificationSettings.news_enabled ? 'bg-accent-500' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||
notificationSettings.news_enabled ? 'left-7' : 'left-1'
|
||||
}`}
|
||||
|
||||
{/* Balance Low */}
|
||||
<div className="space-y-3 border-t border-dark-800/50 pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-dark-100">
|
||||
{t('profile.notifications.balanceLow')}
|
||||
</p>
|
||||
<p className="text-sm text-dark-400">
|
||||
{t('profile.notifications.balanceLowDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={notificationSettings.balance_low_enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleNotificationToggle('balance_low_enabled', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{notificationSettings.balance_low_enabled && (
|
||||
<div className="flex items-center gap-3 pl-4">
|
||||
<span className="text-sm text-dark-400">
|
||||
{t('profile.notifications.threshold')}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={notificationSettings.balance_low_threshold}
|
||||
onChange={(e) =>
|
||||
handleNotificationValue('balance_low_threshold', Number(e.target.value))
|
||||
}
|
||||
min={0}
|
||||
className="input w-24 py-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* News */}
|
||||
<div className="flex items-center justify-between border-t border-dark-800/50 pt-6">
|
||||
<div>
|
||||
<p className="font-medium text-dark-100">{t('profile.notifications.news')}</p>
|
||||
<p className="text-sm text-dark-400">{t('profile.notifications.newsDesc')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={notificationSettings.news_enabled}
|
||||
onCheckedChange={(checked) => handleNotificationToggle('news_enabled', checked)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Promo Offers */}
|
||||
<div className="flex items-center justify-between border-t border-dark-800/50 pt-6">
|
||||
<div>
|
||||
<p className="font-medium text-dark-100">
|
||||
{t('profile.notifications.promoOffers')}
|
||||
</p>
|
||||
<p className="text-sm text-dark-400">
|
||||
{t('profile.notifications.promoOffersDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleNotificationToggle(
|
||||
'promo_offers_enabled',
|
||||
!notificationSettings.promo_offers_enabled,
|
||||
)
|
||||
}
|
||||
className={`relative h-6 w-12 rounded-full transition-colors ${
|
||||
notificationSettings.promo_offers_enabled ? 'bg-accent-500' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||
notificationSettings.promo_offers_enabled ? 'left-7' : 'left-1'
|
||||
}`}
|
||||
|
||||
{/* Promo Offers */}
|
||||
<div className="flex items-center justify-between border-t border-dark-800/50 pt-6">
|
||||
<div>
|
||||
<p className="font-medium text-dark-100">
|
||||
{t('profile.notifications.promoOffers')}
|
||||
</p>
|
||||
<p className="text-sm text-dark-400">
|
||||
{t('profile.notifications.promoOffersDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={notificationSettings.promo_offers_enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleNotificationToggle('promo_offers_enabled', checked)
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-dark-400">{t('profile.notifications.unavailable')}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-dark-400">{t('profile.notifications.unavailable')}</p>
|
||||
)}
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Change Email Modal */}
|
||||
{showChangeEmailModal && user?.email && (
|
||||
@@ -647,6 +594,6 @@ export default function Profile() {
|
||||
currentEmail={user.email}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,8 +138,8 @@ export default function Referral() {
|
||||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('referral.title')}</h1>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="bento-grid">
|
||||
<div className="bento-card-hover">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 md:gap-4">
|
||||
<div className="bento-card-hover col-span-2 md:col-span-1">
|
||||
<div className="text-sm text-dark-400">{t('referral.stats.totalReferrals')}</div>
|
||||
<div className="stat-value mt-1">{info?.total_referrals || 0}</div>
|
||||
<div className="mt-1 text-sm text-dark-500">
|
||||
@@ -152,7 +152,7 @@ export default function Referral() {
|
||||
{formatPositive(info?.total_earnings_rubles || 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bento-card-hover col-span-2 sm:col-span-1">
|
||||
<div className="bento-card-hover">
|
||||
<div className="text-sm text-dark-400">{t('referral.stats.commissionRate')}</div>
|
||||
<div className="stat-value mt-1 text-accent-400">{info?.commission_percent || 0}%</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { AxiosError } from 'axios';
|
||||
import { motion } from 'framer-motion';
|
||||
import { subscriptionApi } from '../api/subscription';
|
||||
import { promoApi } from '../api/promo';
|
||||
import type {
|
||||
@@ -17,6 +18,8 @@ import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import { useCloseOnSuccessNotification } from '../store/successNotification';
|
||||
import i18n from '../i18n';
|
||||
import { Card } from '@/components/data-display/Card';
|
||||
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
|
||||
|
||||
// Helper to extract error message from axios/api errors
|
||||
const getErrorMessage = (error: unknown): string => {
|
||||
@@ -596,227 +599,213 @@ export default function Subscription() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('subscription.title')}</h1>
|
||||
<motion.div
|
||||
className="space-y-6"
|
||||
variants={staggerContainer}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
>
|
||||
<motion.div variants={staggerItem}>
|
||||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('subscription.title')}</h1>
|
||||
</motion.div>
|
||||
|
||||
{/* Current Subscription */}
|
||||
{subscription ? (
|
||||
<div className="bento-card">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{t('subscription.currentPlan')}
|
||||
</h2>
|
||||
{subscription.tariff_name && (
|
||||
<div className="mt-1 text-sm text-accent-400">{subscription.tariff_name}</div>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={
|
||||
subscription.is_active
|
||||
? subscription.is_trial
|
||||
? 'badge-warning'
|
||||
: 'badge-success'
|
||||
: 'badge-error'
|
||||
}
|
||||
>
|
||||
{subscription.is_trial
|
||||
? t('subscription.trialStatus')
|
||||
: subscription.is_active
|
||||
? t('subscription.active')
|
||||
: t('subscription.expired')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Connection Data - Top Priority */}
|
||||
{subscription.subscription_url && (
|
||||
<div className="mb-6 rounded-xl border border-accent-500/30 bg-accent-500/10 p-4">
|
||||
<div className="mb-3 font-medium text-dark-100">
|
||||
{t('subscription.connectionInfo')}
|
||||
</div>
|
||||
|
||||
{/* Get Config Button */}
|
||||
<button
|
||||
onClick={() => setShowConnectionModal(true)}
|
||||
className="btn-primary mb-3 flex w-full items-center justify-center gap-2 py-3"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
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>
|
||||
{t('subscription.getConfig')}
|
||||
</button>
|
||||
|
||||
{/* Subscription URL - hidden when hide_subscription_link is true */}
|
||||
{!subscription.hide_subscription_link && (
|
||||
<div className="flex gap-2">
|
||||
<code className="scrollbar-hide flex-1 overflow-x-auto break-all rounded-lg border border-dark-700/50 bg-dark-900/50 px-3 py-2 text-xs text-dark-300">
|
||||
{subscription.subscription_url}
|
||||
</code>
|
||||
<button
|
||||
onClick={copyUrl}
|
||||
className={`btn-secondary px-3 ${copied ? 'border-success-500/30 text-success-400' : ''}`}
|
||||
title={t('subscription.copyLink')}
|
||||
>
|
||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6 grid grid-cols-2 gap-6 md:grid-cols-4">
|
||||
<div>
|
||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.daysLeft')}</div>
|
||||
<div className="text-xl font-semibold text-dark-100">
|
||||
{subscription.days_left > 0 ? (
|
||||
t('subscription.days', { count: subscription.days_left })
|
||||
) : subscription.hours_left > 0 ? (
|
||||
`${t('subscription.hours', { count: subscription.hours_left })} ${t('subscription.minutes', { count: subscription.minutes_left })}`
|
||||
) : subscription.minutes_left > 0 ? (
|
||||
t('subscription.minutes', { count: subscription.minutes_left })
|
||||
) : (
|
||||
<span className="text-error-400">{t('subscription.expired')}</span>
|
||||
<motion.div variants={staggerItem}>
|
||||
<Card>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{t('subscription.currentPlan')}
|
||||
</h2>
|
||||
{subscription.tariff_name && (
|
||||
<div className="mt-1 text-sm text-accent-400">{subscription.tariff_name}</div>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={
|
||||
subscription.is_active
|
||||
? subscription.is_trial
|
||||
? 'badge-warning'
|
||||
: 'badge-success'
|
||||
: 'badge-error'
|
||||
}
|
||||
>
|
||||
{subscription.is_trial
|
||||
? t('subscription.trialStatus')
|
||||
: subscription.is_active
|
||||
? t('subscription.active')
|
||||
: t('subscription.expired')}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.expiresAt')}</div>
|
||||
<div className="text-xl font-semibold text-dark-100">
|
||||
{new Date(subscription.end_date).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="text-sm text-dark-500">{t('subscription.traffic')}</span>
|
||||
|
||||
{/* Connection Data - Top Priority */}
|
||||
{subscription.subscription_url && (
|
||||
<div className="mb-6 rounded-xl border border-accent-500/30 bg-accent-500/10 p-4">
|
||||
<div className="mb-3 font-medium text-dark-100">
|
||||
{t('subscription.connectionInfo')}
|
||||
</div>
|
||||
|
||||
{/* Get Config Button */}
|
||||
<button
|
||||
onClick={() => refreshTrafficMutation.mutate()}
|
||||
disabled={refreshTrafficMutation.isPending || trafficRefreshCooldown > 0}
|
||||
className="rounded-full p-1 text-dark-400 transition-colors hover:bg-dark-700/50 hover:text-accent-400 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title={
|
||||
trafficRefreshCooldown > 0 ? `${trafficRefreshCooldown}s` : t('common.refresh')
|
||||
}
|
||||
onClick={() => setShowConnectionModal(true)}
|
||||
className="btn-primary mb-3 flex w-full items-center justify-center gap-2 py-3"
|
||||
>
|
||||
<svg
|
||||
className={`h-3.5 w-3.5 ${refreshTrafficMutation.isPending ? 'animate-spin' : ''}`}
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<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"
|
||||
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>
|
||||
{t('subscription.getConfig')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xl font-semibold text-dark-100">
|
||||
{(trafficData?.traffic_used_gb ?? subscription.traffic_used_gb).toFixed(1)} /{' '}
|
||||
{subscription.traffic_limit_gb || '∞'} GB
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.devices')}</div>
|
||||
<div className="text-xl font-semibold text-dark-100">{subscription.device_limit}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Servers */}
|
||||
{subscription.servers && subscription.servers.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 text-sm text-dark-500">{t('subscription.serversLabel')}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{subscription.servers.map((server) => (
|
||||
<span
|
||||
key={server.uuid}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-dark-700/50 bg-dark-800/50 px-3 py-1.5 text-sm text-dark-200"
|
||||
>
|
||||
{server.country_code && (
|
||||
<span className="text-base">{getFlagEmoji(server.country_code)}</span>
|
||||
)}
|
||||
{server.name}
|
||||
</span>
|
||||
))}
|
||||
{/* Subscription URL - hidden when hide_subscription_link is true */}
|
||||
{!subscription.hide_subscription_link && (
|
||||
<div className="flex gap-2">
|
||||
<code className="scrollbar-hide flex-1 overflow-x-auto break-all rounded-lg border border-dark-700/50 bg-dark-900/50 px-3 py-2 text-xs text-dark-300">
|
||||
{subscription.subscription_url}
|
||||
</code>
|
||||
<button
|
||||
onClick={copyUrl}
|
||||
className={`btn-secondary px-3 ${copied ? 'border-success-500/30 text-success-400' : ''}`}
|
||||
title={t('subscription.copyLink')}
|
||||
>
|
||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Traffic Usage */}
|
||||
{subscription.traffic_limit_gb > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 flex justify-between text-sm">
|
||||
<span className="text-dark-400">{t('subscription.trafficUsed')}</span>
|
||||
<span className="text-dark-300">
|
||||
{(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent).toFixed(
|
||||
1,
|
||||
<div className="mb-6 grid grid-cols-2 gap-6 md:grid-cols-4">
|
||||
<div>
|
||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.daysLeft')}</div>
|
||||
<div className="text-xl font-semibold text-dark-100">
|
||||
{subscription.days_left > 0 ? (
|
||||
t('subscription.days', { count: subscription.days_left })
|
||||
) : subscription.hours_left > 0 ? (
|
||||
`${t('subscription.hours', { count: subscription.hours_left })} ${t('subscription.minutes', { count: subscription.minutes_left })}`
|
||||
) : subscription.minutes_left > 0 ? (
|
||||
t('subscription.minutes', { count: subscription.minutes_left })
|
||||
) : (
|
||||
<span className="text-error-400">{t('subscription.expired')}</span>
|
||||
)}
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className={`progress-fill ${getTrafficColor(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent)}`}
|
||||
style={{
|
||||
width: `${Math.min(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent, 100)}%`,
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.expiresAt')}</div>
|
||||
<div className="text-xl font-semibold text-dark-100">
|
||||
{new Date(subscription.end_date).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="text-sm text-dark-500">{t('subscription.traffic')}</span>
|
||||
<button
|
||||
onClick={() => refreshTrafficMutation.mutate()}
|
||||
disabled={refreshTrafficMutation.isPending || trafficRefreshCooldown > 0}
|
||||
className="rounded-full p-1 text-dark-400 transition-colors hover:bg-dark-700/50 hover:text-accent-400 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title={
|
||||
trafficRefreshCooldown > 0
|
||||
? `${trafficRefreshCooldown}s`
|
||||
: t('common.refresh')
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className={`h-3.5 w-3.5 ${refreshTrafficMutation.isPending ? 'animate-spin' : ''}`}
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xl font-semibold text-dark-100">
|
||||
{(trafficData?.traffic_used_gb ?? subscription.traffic_used_gb).toFixed(1)} /{' '}
|
||||
{subscription.traffic_limit_gb || '∞'} GB
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.devices')}</div>
|
||||
<div className="text-xl font-semibold text-dark-100">
|
||||
{subscription.device_limit}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Purchased Traffic Packages */}
|
||||
{subscription.traffic_purchases && subscription.traffic_purchases.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="mb-3 text-sm text-dark-500">{t('subscription.purchasedTraffic')}</div>
|
||||
<div className="space-y-3">
|
||||
{subscription.traffic_purchases.map((purchase) => (
|
||||
{/* Servers */}
|
||||
{subscription.servers && subscription.servers.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 text-sm text-dark-500">{t('subscription.serversLabel')}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{subscription.servers.map((server) => (
|
||||
<span
|
||||
key={server.uuid}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-dark-700/50 bg-dark-800/50 px-3 py-1.5 text-sm text-dark-200"
|
||||
>
|
||||
{server.country_code && (
|
||||
<span className="text-base">{getFlagEmoji(server.country_code)}</span>
|
||||
)}
|
||||
{server.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Traffic Usage */}
|
||||
{subscription.traffic_limit_gb > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 flex justify-between text-sm">
|
||||
<span className="text-dark-400">{t('subscription.trafficUsed')}</span>
|
||||
<span className="text-dark-300">
|
||||
{(
|
||||
trafficData?.traffic_used_percent ?? subscription.traffic_used_percent
|
||||
).toFixed(1)}
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
key={purchase.id}
|
||||
className="rounded-lg border border-dark-700/50 bg-dark-800/50 p-3"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="h-5 w-5 text-accent-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-base font-semibold text-dark-100">
|
||||
{purchase.traffic_gb} {t('common.units.gb')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<div className="text-sm text-dark-400">
|
||||
{purchase.days_remaining === 0 ? (
|
||||
<span className="text-orange-500">{t('subscription.expired')}</span>
|
||||
) : (
|
||||
<span>
|
||||
{t('subscription.days', { count: purchase.days_remaining })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-dark-500">
|
||||
className={`progress-fill ${getTrafficColor(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent)}`}
|
||||
style={{
|
||||
width: `${Math.min(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent, 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Purchased Traffic Packages */}
|
||||
{subscription.traffic_purchases && subscription.traffic_purchases.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="mb-3 text-sm text-dark-500">
|
||||
{t('subscription.purchasedTraffic')}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{subscription.traffic_purchases.map((purchase) => (
|
||||
<div
|
||||
key={purchase.id}
|
||||
className="rounded-lg border border-dark-700/50 bg-dark-800/50 p-3"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
className="h-5 w-5 text-accent-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -825,85 +814,118 @@ export default function Subscription() {
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{t('subscription.trafficResetAt')}:{' '}
|
||||
{new Date(purchase.expires_at).toLocaleDateString(undefined, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})}
|
||||
<span className="text-base font-semibold text-dark-100">
|
||||
{purchase.traffic_gb} {t('common.units.gb')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<div className="text-sm text-dark-400">
|
||||
{purchase.days_remaining === 0 ? (
|
||||
<span className="text-orange-500">{t('subscription.expired')}</span>
|
||||
) : (
|
||||
<span>
|
||||
{t('subscription.days', { count: purchase.days_remaining })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-dark-500">
|
||||
<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>
|
||||
<span>
|
||||
{t('subscription.trafficResetAt')}:{' '}
|
||||
{new Date(purchase.expires_at).toLocaleDateString(undefined, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-1.5 overflow-hidden rounded-full bg-dark-700">
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-r from-accent-500 to-accent-600 transition-all duration-300"
|
||||
style={{ width: `${purchase.progress_percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-xs text-dark-500">
|
||||
<span>{new Date(purchase.created_at).toLocaleDateString()}</span>
|
||||
<span>{new Date(purchase.expires_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-1.5 overflow-hidden rounded-full bg-dark-700">
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-r from-accent-500 to-accent-600 transition-all duration-300"
|
||||
style={{ width: `${purchase.progress_percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-xs text-dark-500">
|
||||
<span>{new Date(purchase.created_at).toLocaleDateString()}</span>
|
||||
<span>{new Date(purchase.expires_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Autopay Toggle - hide for daily tariffs */}
|
||||
{!subscription.is_trial && !subscription.is_daily && (
|
||||
<div className="flex items-center justify-between border-t border-dark-800/50 py-4">
|
||||
<div>
|
||||
<div className="font-medium text-dark-100">{t('subscription.autoRenewal')}</div>
|
||||
<div className="text-sm text-dark-500">
|
||||
{t('subscription.daysBeforeExpiry', { count: subscription.autopay_days_before })}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => autopayMutation.mutate(!subscription.autopay_enabled)}
|
||||
disabled={autopayMutation.isPending}
|
||||
className={`relative h-6 w-12 rounded-full transition-colors ${
|
||||
subscription.autopay_enabled ? 'bg-accent-500' : 'bg-dark-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute left-1 top-1 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||
subscription.autopay_enabled ? 'translate-x-6' : 'translate-x-0'
|
||||
)}
|
||||
|
||||
{/* Autopay Toggle - hide for daily tariffs */}
|
||||
{!subscription.is_trial && !subscription.is_daily && (
|
||||
<div className="flex items-center justify-between border-t border-dark-800/50 py-4">
|
||||
<div>
|
||||
<div className="font-medium text-dark-100">{t('subscription.autoRenewal')}</div>
|
||||
<div className="text-sm text-dark-500">
|
||||
{t('subscription.daysBeforeExpiry', {
|
||||
count: subscription.autopay_days_before,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => autopayMutation.mutate(!subscription.autopay_enabled)}
|
||||
disabled={autopayMutation.isPending}
|
||||
className={`relative h-6 w-12 rounded-full transition-colors ${
|
||||
subscription.autopay_enabled ? 'bg-accent-500' : 'bg-dark-700'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
>
|
||||
<span
|
||||
className={`absolute left-1 top-1 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||
subscription.autopay_enabled ? 'translate-x-6' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="card py-12 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">
|
||||
<svg
|
||||
className="h-8 w-8 text-dark-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="mb-4 text-dark-400">{t('subscription.noSubscription')}</div>
|
||||
</div>
|
||||
<motion.div variants={staggerItem}>
|
||||
<Card className="py-12 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">
|
||||
<svg
|
||||
className="h-8 w-8 text-dark-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="mb-4 text-dark-400">{t('subscription.noSubscription')}</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Daily Subscription Pause */}
|
||||
{subscription && subscription.is_daily && !subscription.is_trial && (
|
||||
<div className="bento-card">
|
||||
<Card>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
@@ -1021,12 +1043,12 @@ export default function Subscription() {
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Additional Options (Buy Devices) */}
|
||||
{subscription && subscription.is_active && !subscription.is_trial && (
|
||||
<div className="bento-card">
|
||||
<Card>
|
||||
<h2 className="mb-4 text-lg font-semibold text-dark-100">
|
||||
{t('subscription.additionalOptions.title')}
|
||||
</h2>
|
||||
@@ -1764,12 +1786,12 @@ export default function Subscription() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* My Devices Section */}
|
||||
{subscription && (
|
||||
<div className="bento-card">
|
||||
<Card>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-dark-100">{t('subscription.myDevices')}</h2>
|
||||
{devicesData && devicesData.devices.length > 0 && (
|
||||
@@ -1855,12 +1877,12 @@ export default function Subscription() {
|
||||
) : (
|
||||
<div className="py-8 text-center text-dark-400">{t('subscription.noDevices')}</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tariffs Section - Combined Purchase/Extend/Switch like MiniApp */}
|
||||
{isTariffsMode && tariffs.length > 0 && (
|
||||
<div ref={tariffsCardRef} className="bento-card">
|
||||
<Card ref={tariffsCardRef}>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{subscription?.is_daily && !subscription?.is_trial
|
||||
@@ -2823,12 +2845,12 @@ export default function Subscription() {
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Purchase/Extend Section - Classic Mode */}
|
||||
{classicOptions && classicOptions.periods.length > 0 && (
|
||||
<div ref={tariffsCardRef} className="bento-card">
|
||||
<Card ref={tariffsCardRef}>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{subscription && !subscription.is_trial
|
||||
@@ -3231,11 +3253,11 @@ export default function Subscription() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Connection Modal */}
|
||||
{showConnectionModal && <ConnectionModal onClose={() => setShowConnectionModal(false)} />}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ticketsApi } from '../api/tickets';
|
||||
import { infoApi } from '../api/info';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { logger } from '../utils/logger';
|
||||
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit';
|
||||
import type { TicketDetail, TicketMessage } from '../types';
|
||||
import { Card } from '@/components/data-display/Card';
|
||||
import { Button } from '@/components/primitives/Button';
|
||||
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
|
||||
|
||||
const log = logger.createLogger('Support');
|
||||
|
||||
@@ -145,6 +150,7 @@ export default function Support() {
|
||||
log.debug('Component loaded');
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { isAdmin } = useAuthStore();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedTicket, setSelectedTicket] = useState<TicketDetail | null>(null);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
@@ -303,7 +309,7 @@ export default function Support() {
|
||||
const supportUsername = supportConfig.support_username || '@support';
|
||||
log.debug('Opening profile:', supportUsername);
|
||||
return {
|
||||
title: t('support.ticketsDisabled'),
|
||||
title: isAdmin ? t('support.ticketsDisabled') : t('support.title'),
|
||||
message: t('support.contactSupport', { username: supportUsername }),
|
||||
buttonText: t('support.contactUs'),
|
||||
buttonAction: () => {
|
||||
@@ -352,7 +358,7 @@ export default function Support() {
|
||||
|
||||
if (supportConfig.support_type === 'url' && supportConfig.support_url) {
|
||||
return {
|
||||
title: t('support.ticketsDisabled'),
|
||||
title: isAdmin ? t('support.ticketsDisabled') : t('support.title'),
|
||||
message: t('support.useExternalLink'),
|
||||
buttonText: t('support.openSupport'),
|
||||
buttonAction: () => {
|
||||
@@ -370,7 +376,7 @@ export default function Support() {
|
||||
const supportUsername = supportConfig.support_username || '@support';
|
||||
log.debug('Fallback: Opening profile:', supportUsername);
|
||||
return {
|
||||
title: t('support.ticketsDisabled'),
|
||||
title: isAdmin ? t('support.ticketsDisabled') : t('support.title'),
|
||||
message: t('support.contactSupport', { username: supportUsername }),
|
||||
buttonText: t('support.contactUs'),
|
||||
buttonAction: () => {
|
||||
@@ -403,7 +409,7 @@ export default function Support() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-12 max-w-md">
|
||||
<div className="bento-card text-center">
|
||||
<Card className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">
|
||||
<svg
|
||||
className="h-8 w-8 text-dark-400"
|
||||
@@ -421,10 +427,10 @@ export default function Support() {
|
||||
</div>
|
||||
<h2 className="mb-2 text-xl font-semibold text-dark-100">{supportMessage.title}</h2>
|
||||
<p className="mb-6 text-dark-400">{supportMessage.message}</p>
|
||||
<button onClick={supportMessage.buttonAction} className="btn-primary w-full">
|
||||
<Button onClick={supportMessage.buttonAction} fullWidth>
|
||||
{supportMessage.buttonText}
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -462,25 +468,32 @@ export default function Support() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<motion.div
|
||||
className="space-y-6"
|
||||
variants={staggerContainer}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
>
|
||||
<motion.div
|
||||
variants={staggerItem}
|
||||
className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('support.title')}</h1>
|
||||
<button
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowCreateForm(true);
|
||||
setSelectedTicket(null);
|
||||
setCreateAttachment(null);
|
||||
}}
|
||||
className="btn-primary"
|
||||
>
|
||||
<PlusIcon />
|
||||
{t('support.newTicket')}
|
||||
</button>
|
||||
</div>
|
||||
<span className="ml-2">{t('support.newTicket')}</span>
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<motion.div variants={staggerItem} className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Tickets List */}
|
||||
<div className="bento-card lg:col-span-1">
|
||||
<Card className="lg:col-span-1">
|
||||
<h2 className="mb-4 text-lg font-semibold text-dark-100">{t('support.yourTickets')}</h2>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -535,10 +548,10 @@ export default function Support() {
|
||||
<div className="text-dark-400">{t('support.noTickets')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Ticket Detail / Create Form */}
|
||||
<div className="bento-card lg:col-span-2">
|
||||
<Card className="lg:col-span-2">
|
||||
{showCreateForm ? (
|
||||
<div>
|
||||
<h2 className="mb-6 text-lg font-semibold text-dark-100">
|
||||
@@ -621,33 +634,24 @@ export default function Support() {
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || createAttachment?.uploading}
|
||||
className="btn-primary"
|
||||
disabled={createAttachment?.uploading}
|
||||
loading={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
{t('support.creating')}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<SendIcon />
|
||||
{t('support.send')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
<SendIcon />
|
||||
<span className="ml-2">{t('support.send')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowCreateForm(false);
|
||||
setCreateAttachment(null);
|
||||
}}
|
||||
className="btn-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -764,21 +768,13 @@ export default function Support() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
replyMutation.isPending ||
|
||||
!replyMessage.trim() ||
|
||||
replyAttachment?.uploading
|
||||
}
|
||||
className="btn-primary"
|
||||
disabled={!replyMessage.trim() || replyAttachment?.uploading}
|
||||
loading={replyMutation.isPending}
|
||||
>
|
||||
{replyMutation.isPending ? (
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
) : (
|
||||
<SendIcon />
|
||||
)}
|
||||
</button>
|
||||
<SendIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{rateLimitError && (
|
||||
<div className="mt-2 rounded-lg border border-error-500/30 bg-error-500/10 p-2 text-sm text-error-400">
|
||||
@@ -815,8 +811,8 @@ export default function Support() {
|
||||
<div className="text-dark-400">{t('support.selectTicket')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
7
src/platform/PlatformContext.tsx
Normal file
7
src/platform/PlatformContext.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createContext } from 'react';
|
||||
import type { PlatformContext as PlatformContextType } from './types';
|
||||
|
||||
// Create the context with undefined default (will be provided by PlatformProvider)
|
||||
export const PlatformContext = createContext<PlatformContextType | null>(null);
|
||||
|
||||
PlatformContext.displayName = 'PlatformContext';
|
||||
43
src/platform/PlatformProvider.tsx
Normal file
43
src/platform/PlatformProvider.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { PlatformContext } from '@/platform/PlatformContext';
|
||||
import { createTelegramAdapter } from '@/platform/adapters/TelegramAdapter';
|
||||
import { createWebAdapter } from '@/platform/adapters/WebAdapter';
|
||||
import type { PlatformContext as PlatformContextType } from '@/platform/types';
|
||||
|
||||
interface PlatformProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function detectPlatform(): 'telegram' | 'web' {
|
||||
// Check if Telegram WebApp is available and actually initialized with data
|
||||
// initData is an empty string when not in real Telegram context
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
window.Telegram?.WebApp?.initData &&
|
||||
window.Telegram.WebApp.initData.length > 0
|
||||
) {
|
||||
return 'telegram';
|
||||
}
|
||||
return 'web';
|
||||
}
|
||||
|
||||
function createAdapter(): PlatformContextType {
|
||||
const platform = detectPlatform();
|
||||
|
||||
if (platform === 'telegram') {
|
||||
return createTelegramAdapter();
|
||||
}
|
||||
|
||||
return createWebAdapter();
|
||||
}
|
||||
|
||||
export function PlatformProvider({ children }: PlatformProviderProps) {
|
||||
// Create adapter once on mount
|
||||
// Using useMemo to ensure stable reference
|
||||
const platformContext = useMemo(() => createAdapter(), []);
|
||||
|
||||
return <PlatformContext.Provider value={platformContext}>{children}</PlatformContext.Provider>;
|
||||
}
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { PlatformContextType as PlatformContext };
|
||||
375
src/platform/adapters/TelegramAdapter.ts
Normal file
375
src/platform/adapters/TelegramAdapter.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import type {
|
||||
PlatformContext,
|
||||
PlatformCapabilities,
|
||||
BackButtonController,
|
||||
MainButtonController,
|
||||
HapticController,
|
||||
DialogController,
|
||||
ThemeController,
|
||||
CloudStorageController,
|
||||
MainButtonConfig,
|
||||
PopupOptions,
|
||||
InvoiceStatus,
|
||||
HapticImpactStyle,
|
||||
HapticNotificationType,
|
||||
} from '@/platform/types';
|
||||
|
||||
function getTelegram(): TelegramWebApp | null {
|
||||
return window.Telegram?.WebApp ?? null;
|
||||
}
|
||||
|
||||
function isVersionAtLeast(version: string): boolean {
|
||||
const tg = getTelegram();
|
||||
if (!tg) return false;
|
||||
return tg.isVersionAtLeast?.(version) ?? false;
|
||||
}
|
||||
|
||||
function createCapabilities(): PlatformCapabilities {
|
||||
const tg = getTelegram();
|
||||
return {
|
||||
hasBackButton: !!tg?.BackButton,
|
||||
hasMainButton: !!tg?.MainButton,
|
||||
hasHapticFeedback: isVersionAtLeast('6.1') && !!tg?.HapticFeedback,
|
||||
hasNativeDialogs: isVersionAtLeast('6.2'),
|
||||
hasThemeSync: isVersionAtLeast('6.1'),
|
||||
hasInvoice: !!tg?.openInvoice,
|
||||
hasCloudStorage: isVersionAtLeast('6.9') && !!tg?.CloudStorage,
|
||||
hasShare: true, // openTelegramLink is always available
|
||||
version: tg?.version,
|
||||
};
|
||||
}
|
||||
|
||||
function createBackButtonController(): BackButtonController {
|
||||
const tg = getTelegram();
|
||||
let currentCallback: (() => void) | null = null;
|
||||
|
||||
return {
|
||||
get isVisible() {
|
||||
return tg?.BackButton?.isVisible ?? false;
|
||||
},
|
||||
|
||||
show(onClick: () => void) {
|
||||
if (!tg?.BackButton) return;
|
||||
|
||||
// Remove previous callback if exists
|
||||
if (currentCallback) {
|
||||
tg.BackButton.offClick(currentCallback);
|
||||
}
|
||||
|
||||
currentCallback = onClick;
|
||||
tg.BackButton.onClick(onClick);
|
||||
tg.BackButton.show();
|
||||
},
|
||||
|
||||
hide() {
|
||||
if (!tg?.BackButton) return;
|
||||
|
||||
if (currentCallback) {
|
||||
tg.BackButton.offClick(currentCallback);
|
||||
currentCallback = null;
|
||||
}
|
||||
tg.BackButton.hide();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createMainButtonController(): MainButtonController {
|
||||
const tg = getTelegram();
|
||||
let currentCallback: (() => void) | null = null;
|
||||
|
||||
return {
|
||||
get isVisible() {
|
||||
return tg?.MainButton?.isVisible ?? false;
|
||||
},
|
||||
|
||||
show(config: MainButtonConfig) {
|
||||
if (!tg?.MainButton) return;
|
||||
|
||||
// Remove previous callback if exists
|
||||
if (currentCallback) {
|
||||
tg.MainButton.offClick(currentCallback);
|
||||
}
|
||||
|
||||
currentCallback = config.onClick;
|
||||
|
||||
// Set button parameters
|
||||
if (tg.MainButton.setParams) {
|
||||
tg.MainButton.setParams({
|
||||
text: config.text,
|
||||
color: config.color,
|
||||
text_color: config.textColor,
|
||||
is_active: config.isActive !== false,
|
||||
is_visible: true,
|
||||
});
|
||||
} else {
|
||||
tg.MainButton.text = config.text;
|
||||
if (config.color) tg.MainButton.color = config.color;
|
||||
if (config.textColor) tg.MainButton.textColor = config.textColor;
|
||||
}
|
||||
|
||||
tg.MainButton.onClick(config.onClick);
|
||||
|
||||
if (config.isActive === false) {
|
||||
tg.MainButton.disable();
|
||||
} else {
|
||||
tg.MainButton.enable();
|
||||
}
|
||||
|
||||
if (config.isLoading) {
|
||||
tg.MainButton.showProgress?.(true);
|
||||
} else {
|
||||
tg.MainButton.hideProgress?.();
|
||||
}
|
||||
|
||||
tg.MainButton.show();
|
||||
},
|
||||
|
||||
hide() {
|
||||
if (!tg?.MainButton) return;
|
||||
|
||||
if (currentCallback) {
|
||||
tg.MainButton.offClick(currentCallback);
|
||||
currentCallback = null;
|
||||
}
|
||||
|
||||
tg.MainButton.hideProgress?.();
|
||||
tg.MainButton.hide();
|
||||
},
|
||||
|
||||
showProgress(show: boolean) {
|
||||
if (!tg?.MainButton) return;
|
||||
|
||||
if (show) {
|
||||
tg.MainButton.showProgress?.(true);
|
||||
} else {
|
||||
tg.MainButton.hideProgress?.();
|
||||
}
|
||||
},
|
||||
|
||||
setText(text: string) {
|
||||
if (!tg?.MainButton) return;
|
||||
|
||||
if (tg.MainButton.setText) {
|
||||
tg.MainButton.setText(text);
|
||||
} else {
|
||||
tg.MainButton.text = text;
|
||||
}
|
||||
},
|
||||
|
||||
setActive(active: boolean) {
|
||||
if (!tg?.MainButton) return;
|
||||
|
||||
if (active) {
|
||||
tg.MainButton.enable();
|
||||
} else {
|
||||
tg.MainButton.disable();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createHapticController(): HapticController {
|
||||
const tg = getTelegram();
|
||||
|
||||
return {
|
||||
impact(style: HapticImpactStyle = 'medium') {
|
||||
tg?.HapticFeedback?.impactOccurred(style);
|
||||
},
|
||||
|
||||
notification(type: HapticNotificationType) {
|
||||
tg?.HapticFeedback?.notificationOccurred(type);
|
||||
},
|
||||
|
||||
selection() {
|
||||
tg?.HapticFeedback?.selectionChanged();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createDialogController(): DialogController {
|
||||
const tg = getTelegram();
|
||||
|
||||
return {
|
||||
alert(message: string, _title?: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (tg?.showAlert) {
|
||||
tg.showAlert(message, () => resolve());
|
||||
} else {
|
||||
window.alert(message);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
confirm(message: string, _title?: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if (tg?.showConfirm) {
|
||||
tg.showConfirm(message, (confirmed) => resolve(confirmed));
|
||||
} else {
|
||||
resolve(window.confirm(message));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
popup(options: PopupOptions): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
if (tg?.showPopup) {
|
||||
tg.showPopup(
|
||||
{
|
||||
title: options.title,
|
||||
message: options.message,
|
||||
buttons: options.buttons?.map((btn) => ({
|
||||
id: btn.id,
|
||||
type: btn.type,
|
||||
text: btn.text,
|
||||
})),
|
||||
},
|
||||
(buttonId) => resolve(buttonId),
|
||||
);
|
||||
} else {
|
||||
// Fallback to confirm for web
|
||||
const confirmed = window.confirm(options.message);
|
||||
resolve(confirmed ? 'ok' : null);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createThemeController(): ThemeController {
|
||||
const tg = getTelegram();
|
||||
|
||||
return {
|
||||
setHeaderColor(color: string) {
|
||||
tg?.setHeaderColor?.(color);
|
||||
},
|
||||
|
||||
setBottomBarColor(color: string) {
|
||||
tg?.setBottomBarColor?.(color);
|
||||
},
|
||||
|
||||
getThemeParams() {
|
||||
return tg?.themeParams ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createCloudStorageController(): CloudStorageController | null {
|
||||
const tg = getTelegram();
|
||||
if (!tg?.CloudStorage) return null;
|
||||
|
||||
return {
|
||||
getItem(key: string): Promise<string | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
tg.CloudStorage.getItem(key, (error, value) => {
|
||||
if (error) reject(error);
|
||||
else resolve(value || null);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
setItem(key: string, value: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
tg.CloudStorage.setItem(key, value, (error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
removeItem(key: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
tg.CloudStorage.removeItem(key, (error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
getKeys(): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
tg.CloudStorage.getKeys((error, keys) => {
|
||||
if (error) reject(error);
|
||||
else resolve(keys);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createTelegramAdapter(): PlatformContext {
|
||||
const tg = getTelegram();
|
||||
|
||||
return {
|
||||
platform: 'telegram',
|
||||
capabilities: createCapabilities(),
|
||||
backButton: createBackButtonController(),
|
||||
mainButton: createMainButtonController(),
|
||||
haptic: createHapticController(),
|
||||
dialog: createDialogController(),
|
||||
theme: createThemeController(),
|
||||
cloudStorage: createCloudStorageController(),
|
||||
|
||||
openInvoice(url: string): Promise<InvoiceStatus> {
|
||||
return new Promise((resolve) => {
|
||||
if (tg?.openInvoice) {
|
||||
tg.openInvoice(url, (status) => resolve(status));
|
||||
} else {
|
||||
// Fallback: open in new window
|
||||
window.open(url, '_blank');
|
||||
resolve('pending');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
openLink(url: string, options?: { tryInstantView?: boolean }) {
|
||||
if (tg?.openLink) {
|
||||
tg.openLink(url, { try_instant_view: options?.tryInstantView });
|
||||
} else {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
},
|
||||
|
||||
openTelegramLink(url: string) {
|
||||
if (tg?.openTelegramLink) {
|
||||
tg.openTelegramLink(url);
|
||||
} else {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
},
|
||||
|
||||
async share(text: string, url?: string): Promise<boolean> {
|
||||
const shareText = url ? `${text}\n${url}` : text;
|
||||
|
||||
// Try native share API first (if available on mobile)
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({ text: shareText, url });
|
||||
return true;
|
||||
} catch {
|
||||
// User cancelled or share failed, continue to Telegram share
|
||||
}
|
||||
}
|
||||
|
||||
// Use Telegram share
|
||||
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME;
|
||||
if (botUsername && tg?.openTelegramLink) {
|
||||
const encoded = encodeURIComponent(shareText);
|
||||
tg.openTelegramLink(`https://t.me/share/url?url=${encoded}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
setClosingConfirmation(enabled: boolean) {
|
||||
if (enabled) {
|
||||
tg?.enableClosingConfirmation?.();
|
||||
} else {
|
||||
tg?.disableClosingConfirmation?.();
|
||||
}
|
||||
},
|
||||
|
||||
telegram: tg,
|
||||
};
|
||||
}
|
||||
277
src/platform/adapters/WebAdapter.ts
Normal file
277
src/platform/adapters/WebAdapter.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import type {
|
||||
PlatformContext,
|
||||
PlatformCapabilities,
|
||||
BackButtonController,
|
||||
MainButtonController,
|
||||
HapticController,
|
||||
DialogController,
|
||||
ThemeController,
|
||||
CloudStorageController,
|
||||
MainButtonConfig,
|
||||
PopupOptions,
|
||||
InvoiceStatus,
|
||||
HapticImpactStyle,
|
||||
HapticNotificationType,
|
||||
} from '@/platform/types';
|
||||
|
||||
// Storage key for local storage fallback
|
||||
const STORAGE_PREFIX = 'bedolaga_';
|
||||
|
||||
function createCapabilities(): PlatformCapabilities {
|
||||
return {
|
||||
hasBackButton: false, // No native back button in web
|
||||
hasMainButton: false, // No native main button in web
|
||||
hasHapticFeedback: 'vibrate' in navigator, // Web Vibration API
|
||||
hasNativeDialogs: false, // Use custom dialogs
|
||||
hasThemeSync: false, // No header/bottom bar sync in web
|
||||
hasInvoice: false, // No native invoice in web
|
||||
hasCloudStorage: true, // Use localStorage
|
||||
hasShare: 'share' in navigator, // Web Share API
|
||||
version: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function createBackButtonController(): BackButtonController {
|
||||
// Web doesn't have a native back button - this is a no-op
|
||||
// The UI will render its own back buttons
|
||||
return {
|
||||
isVisible: false,
|
||||
|
||||
show(_onClick: () => void) {
|
||||
// No-op in web - handled by UI components
|
||||
},
|
||||
|
||||
hide() {
|
||||
// No-op in web
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createMainButtonController(): MainButtonController {
|
||||
// Web doesn't have a native main button - this is a no-op
|
||||
// The UI will render its own submit buttons
|
||||
return {
|
||||
isVisible: false,
|
||||
|
||||
show(_config: MainButtonConfig) {
|
||||
// No-op in web - handled by UI components
|
||||
},
|
||||
|
||||
hide() {
|
||||
// No-op in web
|
||||
},
|
||||
|
||||
showProgress(_show: boolean) {
|
||||
// No-op in web
|
||||
},
|
||||
|
||||
setText(_text: string) {
|
||||
// No-op in web
|
||||
},
|
||||
|
||||
setActive(_active: boolean) {
|
||||
// No-op in web
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createHapticController(): HapticController {
|
||||
// Web Vibration API fallback (works on mobile browsers)
|
||||
const canVibrate = 'vibrate' in navigator;
|
||||
|
||||
const vibrationPatterns: Record<HapticImpactStyle, number[]> = {
|
||||
light: [10],
|
||||
medium: [20],
|
||||
heavy: [30],
|
||||
rigid: [15, 10, 15],
|
||||
soft: [5, 5, 5],
|
||||
};
|
||||
|
||||
const notificationPatterns: Record<HapticNotificationType, number[]> = {
|
||||
success: [10, 50, 10],
|
||||
warning: [20, 50, 20],
|
||||
error: [30, 30, 30, 30, 30],
|
||||
};
|
||||
|
||||
return {
|
||||
impact(style: HapticImpactStyle = 'medium') {
|
||||
if (canVibrate) {
|
||||
navigator.vibrate(vibrationPatterns[style]);
|
||||
}
|
||||
},
|
||||
|
||||
notification(type: HapticNotificationType) {
|
||||
if (canVibrate) {
|
||||
navigator.vibrate(notificationPatterns[type]);
|
||||
}
|
||||
},
|
||||
|
||||
selection() {
|
||||
if (canVibrate) {
|
||||
navigator.vibrate(5);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createDialogController(): DialogController {
|
||||
// Web uses native browser dialogs as fallback
|
||||
// UI components can override with custom modals
|
||||
return {
|
||||
alert(message: string, _title?: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
window.alert(message);
|
||||
resolve();
|
||||
});
|
||||
},
|
||||
|
||||
confirm(message: string, _title?: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
resolve(window.confirm(message));
|
||||
});
|
||||
},
|
||||
|
||||
popup(options: PopupOptions): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
// Simple confirm fallback
|
||||
const confirmed = window.confirm(options.message);
|
||||
if (confirmed && options.buttons?.length) {
|
||||
// Return first non-cancel button id
|
||||
const button = options.buttons.find((b) => b.type !== 'cancel' && b.type !== 'close');
|
||||
resolve(button?.id ?? 'ok');
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createThemeController(): ThemeController {
|
||||
// Web doesn't have native theme sync
|
||||
// These are no-ops; theme is handled via CSS
|
||||
return {
|
||||
setHeaderColor(_color: string) {
|
||||
// Could update meta theme-color tag if needed
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) {
|
||||
meta.setAttribute('content', _color);
|
||||
}
|
||||
},
|
||||
|
||||
setBottomBarColor(_color: string) {
|
||||
// No-op in web - no bottom bar to sync
|
||||
},
|
||||
|
||||
getThemeParams() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createCloudStorageController(): CloudStorageController {
|
||||
// Use localStorage as cloud storage fallback
|
||||
return {
|
||||
async getItem(key: string): Promise<string | null> {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_PREFIX + key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async setItem(key: string, value: string): Promise<void> {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_PREFIX + key, value);
|
||||
} catch {
|
||||
// Storage might be full or disabled
|
||||
console.warn('Failed to save to localStorage:', key);
|
||||
}
|
||||
},
|
||||
|
||||
async removeItem(key: string): Promise<void> {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_PREFIX + key);
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
},
|
||||
|
||||
async getKeys(): Promise<string[]> {
|
||||
try {
|
||||
const keys: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key?.startsWith(STORAGE_PREFIX)) {
|
||||
keys.push(key.slice(STORAGE_PREFIX.length));
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createWebAdapter(): PlatformContext {
|
||||
return {
|
||||
platform: 'web',
|
||||
capabilities: createCapabilities(),
|
||||
backButton: createBackButtonController(),
|
||||
mainButton: createMainButtonController(),
|
||||
haptic: createHapticController(),
|
||||
dialog: createDialogController(),
|
||||
theme: createThemeController(),
|
||||
cloudStorage: createCloudStorageController(),
|
||||
|
||||
openInvoice(_url: string): Promise<InvoiceStatus> {
|
||||
// Web can't handle Telegram invoices natively
|
||||
// Open in new tab and return pending
|
||||
window.open(_url, '_blank');
|
||||
return Promise.resolve('pending');
|
||||
},
|
||||
|
||||
openLink(url: string, _options?: { tryInstantView?: boolean }) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
},
|
||||
|
||||
openTelegramLink(url: string) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
},
|
||||
|
||||
async share(text: string, url?: string): Promise<boolean> {
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({ text, url });
|
||||
return true;
|
||||
} catch {
|
||||
// User cancelled share
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: copy to clipboard
|
||||
try {
|
||||
const shareText = url ? `${text}\n${url}` : text;
|
||||
await navigator.clipboard.writeText(shareText);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
setClosingConfirmation(enabled: boolean) {
|
||||
if (enabled) {
|
||||
window.onbeforeunload = (e) => {
|
||||
e.preventDefault();
|
||||
return '';
|
||||
};
|
||||
} else {
|
||||
window.onbeforeunload = null;
|
||||
}
|
||||
},
|
||||
|
||||
telegram: null,
|
||||
};
|
||||
}
|
||||
75
src/platform/hooks/useBackButton.ts
Normal file
75
src/platform/hooks/useBackButton.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { usePlatform } from '@/platform/hooks/usePlatform';
|
||||
|
||||
interface UseBackButtonOptions {
|
||||
/**
|
||||
* Whether the back button should be visible
|
||||
* @default true
|
||||
*/
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage the Telegram BackButton
|
||||
* Automatically shows/hides based on component lifecycle
|
||||
*
|
||||
* @param onBack - Callback when back button is pressed
|
||||
* @param options - Configuration options
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyPage() {
|
||||
* const navigate = useNavigate();
|
||||
* useBackButton(() => navigate(-1));
|
||||
* // ...
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useBackButton(
|
||||
onBack: (() => void) | null | undefined,
|
||||
options: UseBackButtonOptions = {},
|
||||
): void {
|
||||
const { backButton, capabilities } = usePlatform();
|
||||
const { visible = true } = options;
|
||||
|
||||
// Use ref to prevent callback recreation issues
|
||||
const callbackRef = useRef(onBack);
|
||||
callbackRef.current = onBack;
|
||||
|
||||
// Stable callback wrapper
|
||||
const handleBack = useCallback(() => {
|
||||
callbackRef.current?.();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// If no native back button support, do nothing
|
||||
if (!capabilities.hasBackButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If callback is null/undefined or visible is false, hide button
|
||||
if (!onBack || !visible) {
|
||||
backButton.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the back button with our handler
|
||||
backButton.show(handleBack);
|
||||
|
||||
// Cleanup: hide button when component unmounts
|
||||
return () => {
|
||||
backButton.hide();
|
||||
};
|
||||
}, [backButton, capabilities.hasBackButton, handleBack, onBack, visible]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to conditionally show back button based on navigation depth
|
||||
* Useful for showing back button only when there's history to go back to
|
||||
*
|
||||
* @param canGoBack - Whether navigation back is possible
|
||||
* @param onBack - Callback when back button is pressed
|
||||
*/
|
||||
export function useConditionalBackButton(canGoBack: boolean, onBack: () => void): void {
|
||||
useBackButton(canGoBack ? onBack : null);
|
||||
}
|
||||
125
src/platform/hooks/useHaptic.ts
Normal file
125
src/platform/hooks/useHaptic.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useCallback } from 'react';
|
||||
import { usePlatform } from '@/platform/hooks/usePlatform';
|
||||
import type { HapticImpactStyle, HapticNotificationType } from '@/platform/types';
|
||||
|
||||
interface HapticMethods {
|
||||
/**
|
||||
* Trigger impact feedback (for button presses, collisions)
|
||||
*/
|
||||
impact: (style?: HapticImpactStyle) => void;
|
||||
|
||||
/**
|
||||
* Trigger notification feedback (for success/warning/error events)
|
||||
*/
|
||||
notification: (type: HapticNotificationType) => void;
|
||||
|
||||
/**
|
||||
* Trigger selection feedback (for selection changes)
|
||||
*/
|
||||
selection: () => void;
|
||||
|
||||
/**
|
||||
* Whether haptic feedback is available
|
||||
*/
|
||||
isAvailable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access haptic feedback
|
||||
* Works in Telegram Mini Apps and falls back to Web Vibration API
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyButton() {
|
||||
* const haptic = useHaptic();
|
||||
*
|
||||
* const handleClick = () => {
|
||||
* haptic.impact('medium');
|
||||
* doSomething();
|
||||
* };
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useHaptic(): HapticMethods {
|
||||
const { haptic, capabilities } = usePlatform();
|
||||
|
||||
const impact = useCallback(
|
||||
(style: HapticImpactStyle = 'medium') => {
|
||||
haptic.impact(style);
|
||||
},
|
||||
[haptic],
|
||||
);
|
||||
|
||||
const notification = useCallback(
|
||||
(type: HapticNotificationType) => {
|
||||
haptic.notification(type);
|
||||
},
|
||||
[haptic],
|
||||
);
|
||||
|
||||
const selection = useCallback(() => {
|
||||
haptic.selection();
|
||||
}, [haptic]);
|
||||
|
||||
return {
|
||||
impact,
|
||||
notification,
|
||||
selection,
|
||||
isAvailable: capabilities.hasHapticFeedback,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that returns a click handler with haptic feedback
|
||||
* Useful for buttons that need haptic on press
|
||||
*
|
||||
* @param onClick - Original click handler
|
||||
* @param style - Haptic impact style
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyButton({ onClick }) {
|
||||
* const handleClick = useHapticClick(onClick, 'light');
|
||||
* return <button onClick={handleClick}>Press me</button>;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useHapticClick(
|
||||
onClick: (() => void) | undefined,
|
||||
style: HapticImpactStyle = 'light',
|
||||
): (() => void) | undefined {
|
||||
const haptic = useHaptic();
|
||||
|
||||
return useCallback(() => {
|
||||
haptic.impact(style);
|
||||
onClick?.();
|
||||
}, [haptic, onClick, style]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns individual haptic trigger functions
|
||||
* Useful when you need specific feedback types
|
||||
*/
|
||||
export function useHapticFeedback() {
|
||||
const haptic = useHaptic();
|
||||
|
||||
return {
|
||||
// Common actions
|
||||
buttonPress: useCallback(() => haptic.impact('light'), [haptic]),
|
||||
buttonPressHeavy: useCallback(() => haptic.impact('medium'), [haptic]),
|
||||
toggle: useCallback(() => haptic.impact('rigid'), [haptic]),
|
||||
|
||||
// Notifications
|
||||
success: useCallback(() => haptic.notification('success'), [haptic]),
|
||||
warning: useCallback(() => haptic.notification('warning'), [haptic]),
|
||||
error: useCallback(() => haptic.notification('error'), [haptic]),
|
||||
|
||||
// Selection
|
||||
selectionChanged: useCallback(() => haptic.selection(), [haptic]),
|
||||
|
||||
// Raw access
|
||||
impact: haptic.impact,
|
||||
notification: haptic.notification,
|
||||
selection: haptic.selection,
|
||||
};
|
||||
}
|
||||
111
src/platform/hooks/useMainButton.ts
Normal file
111
src/platform/hooks/useMainButton.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { usePlatform } from '@/platform/hooks/usePlatform';
|
||||
import type { MainButtonConfig } from '@/platform/types';
|
||||
|
||||
interface UseMainButtonOptions extends Omit<MainButtonConfig, 'onClick'> {
|
||||
/**
|
||||
* Whether the main button should be visible
|
||||
* @default true
|
||||
*/
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage the Telegram MainButton
|
||||
* Automatically shows/hides based on component lifecycle
|
||||
*
|
||||
* @param onClick - Callback when main button is pressed
|
||||
* @param options - Configuration options
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function SubmitForm() {
|
||||
* const mutation = useMutation(...);
|
||||
*
|
||||
* useMainButton(handleSubmit, {
|
||||
* text: t('actions.submit'),
|
||||
* isLoading: mutation.isPending,
|
||||
* isActive: isFormValid,
|
||||
* });
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useMainButton(
|
||||
onClick: (() => void) | null | undefined,
|
||||
options: UseMainButtonOptions = { text: '' },
|
||||
): void {
|
||||
const { mainButton, capabilities } = usePlatform();
|
||||
const { visible = true, text, isLoading, isActive, color, textColor } = options;
|
||||
|
||||
// Use ref to prevent callback recreation issues
|
||||
const callbackRef = useRef(onClick);
|
||||
callbackRef.current = onClick;
|
||||
|
||||
// Stable callback wrapper
|
||||
const handleClick = useCallback(() => {
|
||||
callbackRef.current?.();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// If no native main button support, do nothing
|
||||
// UI components will render their own submit buttons
|
||||
if (!capabilities.hasMainButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If callback is null/undefined or visible is false, hide button
|
||||
if (!onClick || !visible || !text) {
|
||||
mainButton.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the main button with configuration
|
||||
mainButton.show({
|
||||
text,
|
||||
onClick: handleClick,
|
||||
isLoading,
|
||||
isActive,
|
||||
color,
|
||||
textColor,
|
||||
});
|
||||
|
||||
// Cleanup: hide button when component unmounts
|
||||
return () => {
|
||||
mainButton.hide();
|
||||
};
|
||||
}, [
|
||||
mainButton,
|
||||
capabilities.hasMainButton,
|
||||
handleClick,
|
||||
onClick,
|
||||
visible,
|
||||
text,
|
||||
isLoading,
|
||||
isActive,
|
||||
color,
|
||||
textColor,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for simple main button usage
|
||||
* Shows button only when conditions are met
|
||||
*
|
||||
* @param text - Button text
|
||||
* @param onClick - Click handler
|
||||
* @param enabled - Whether button should be shown and active
|
||||
* @param loading - Whether to show loading state
|
||||
*/
|
||||
export function useSimpleMainButton(
|
||||
text: string,
|
||||
onClick: () => void,
|
||||
enabled: boolean = true,
|
||||
loading: boolean = false,
|
||||
): void {
|
||||
useMainButton(enabled ? onClick : null, {
|
||||
text,
|
||||
isLoading: loading,
|
||||
isActive: enabled && !loading,
|
||||
visible: enabled,
|
||||
});
|
||||
}
|
||||
116
src/platform/hooks/useNativeDialog.ts
Normal file
116
src/platform/hooks/useNativeDialog.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useCallback } from 'react';
|
||||
import { usePlatform } from '@/platform/hooks/usePlatform';
|
||||
import type { PopupOptions, PopupButton } from '@/platform/types';
|
||||
|
||||
interface DialogMethods {
|
||||
/**
|
||||
* Show a simple alert dialog
|
||||
* @param message - Alert message
|
||||
* @param title - Optional title (only shown if platform supports it)
|
||||
*/
|
||||
alert: (message: string, title?: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Show a confirmation dialog
|
||||
* @param message - Confirmation message
|
||||
* @param title - Optional title
|
||||
* @returns true if confirmed, false otherwise
|
||||
*/
|
||||
confirm: (message: string, title?: string) => Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Show a popup with custom buttons
|
||||
* @param options - Popup configuration
|
||||
* @returns ID of the pressed button, or null if cancelled
|
||||
*/
|
||||
popup: (options: PopupOptions) => Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Whether native dialogs are available
|
||||
*/
|
||||
isNative: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access native dialog functionality
|
||||
* Uses Telegram popups in Mini Apps, browser dialogs in web
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const dialog = useNativeDialog();
|
||||
*
|
||||
* const handleDelete = async () => {
|
||||
* const confirmed = await dialog.confirm('Delete this item?');
|
||||
* if (confirmed) {
|
||||
* deleteItem();
|
||||
* }
|
||||
* };
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useNativeDialog(): DialogMethods {
|
||||
const { dialog, capabilities } = usePlatform();
|
||||
|
||||
const alert = useCallback(
|
||||
async (message: string, title?: string) => {
|
||||
await dialog.alert(message, title);
|
||||
},
|
||||
[dialog],
|
||||
);
|
||||
|
||||
const confirm = useCallback(
|
||||
async (message: string, title?: string) => {
|
||||
return dialog.confirm(message, title);
|
||||
},
|
||||
[dialog],
|
||||
);
|
||||
|
||||
const popup = useCallback(
|
||||
async (options: PopupOptions) => {
|
||||
return dialog.popup(options);
|
||||
},
|
||||
[dialog],
|
||||
);
|
||||
|
||||
return {
|
||||
alert,
|
||||
confirm,
|
||||
popup,
|
||||
isNative: capabilities.hasNativeDialogs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create popup button configurations
|
||||
*/
|
||||
export const PopupButtons = {
|
||||
ok: (text = 'OK'): PopupButton => ({ id: 'ok', type: 'ok', text }),
|
||||
cancel: (text = 'Cancel'): PopupButton => ({ id: 'cancel', type: 'cancel', text }),
|
||||
close: (text = 'Close'): PopupButton => ({ id: 'close', type: 'close', text }),
|
||||
destructive: (id: string, text: string): PopupButton => ({ id, type: 'destructive', text }),
|
||||
default: (id: string, text: string): PopupButton => ({ id, type: 'default', text }),
|
||||
};
|
||||
|
||||
/**
|
||||
* Show a destructive action confirmation
|
||||
* Red "Delete" button style in Telegram
|
||||
*/
|
||||
export function useDestructiveConfirm() {
|
||||
const dialog = useNativeDialog();
|
||||
|
||||
return useCallback(
|
||||
async (message: string, actionText = 'Delete', title?: string) => {
|
||||
if (dialog.isNative) {
|
||||
const result = await dialog.popup({
|
||||
title,
|
||||
message,
|
||||
buttons: [PopupButtons.cancel(), PopupButtons.destructive('confirm', actionText)],
|
||||
});
|
||||
return result === 'confirm';
|
||||
}
|
||||
return dialog.confirm(message, title);
|
||||
},
|
||||
[dialog],
|
||||
);
|
||||
}
|
||||
35
src/platform/hooks/usePlatform.ts
Normal file
35
src/platform/hooks/usePlatform.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useContext } from 'react';
|
||||
import { PlatformContext } from '@/platform/PlatformContext';
|
||||
import type { PlatformContext as PlatformContextType } from '@/platform/types';
|
||||
|
||||
/**
|
||||
* Hook to access the platform context
|
||||
* Provides platform-aware APIs for Telegram Mini Apps and web fallback
|
||||
*/
|
||||
export function usePlatform(): PlatformContextType {
|
||||
const context = useContext(PlatformContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('usePlatform must be used within a PlatformProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in Telegram Mini App
|
||||
*/
|
||||
export function useIsTelegram(): boolean {
|
||||
const { platform } = usePlatform();
|
||||
return platform === 'telegram';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific capability is available
|
||||
*/
|
||||
export function useCapability(capability: keyof PlatformContextType['capabilities']): boolean {
|
||||
const { capabilities } = usePlatform();
|
||||
const value = capabilities[capability];
|
||||
// version is the only string capability, all others are boolean
|
||||
return typeof value === 'boolean' ? value : !!value;
|
||||
}
|
||||
33
src/platform/index.ts
Normal file
33
src/platform/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Platform abstraction layer
|
||||
// Provides unified APIs for Telegram Mini Apps and web browser
|
||||
|
||||
// Context and Provider
|
||||
export { PlatformContext } from './PlatformContext';
|
||||
export { PlatformProvider } from './PlatformProvider';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
PlatformType,
|
||||
PlatformContext as PlatformContextType,
|
||||
PlatformCapabilities,
|
||||
MainButtonConfig,
|
||||
PopupOptions,
|
||||
PopupButton,
|
||||
InvoiceStatus,
|
||||
HapticImpactStyle,
|
||||
HapticNotificationType,
|
||||
BackButtonController,
|
||||
MainButtonController,
|
||||
HapticController,
|
||||
DialogController,
|
||||
ThemeController,
|
||||
CloudStorageController,
|
||||
TelegramThemeParams,
|
||||
} from './types';
|
||||
|
||||
// Hooks
|
||||
export { usePlatform, useIsTelegram, useCapability } from './hooks/usePlatform';
|
||||
export { useBackButton, useConditionalBackButton } from './hooks/useBackButton';
|
||||
export { useMainButton, useSimpleMainButton } from './hooks/useMainButton';
|
||||
export { useHaptic, useHapticClick, useHapticFeedback } from './hooks/useHaptic';
|
||||
export { useNativeDialog, useDestructiveConfirm, PopupButtons } from './hooks/useNativeDialog';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user