mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +00:00
Merge pull request #141 from BEDOLAGA-DEV/feat/linear-ui-redesign
feat/linear UI redesign
This commit is contained in:
1547
package-lock.json
generated
1547
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@@ -21,15 +21,33 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@lottiefiles/dotlottie-react": "^0.8.0",
|
"@lottiefiles/dotlottie-react": "^0.8.0",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||||
"@tanstack/react-query": "^5.8.0",
|
"@tanstack/react-query": "^5.8.0",
|
||||||
|
"@telegram-apps/react-router-integration": "^1.0.1",
|
||||||
|
"@telegram-apps/sdk-react": "^3.3.9",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
|
"framer-motion": "^12.29.2",
|
||||||
"i18next": "^23.7.0",
|
"i18next": "^23.7.0",
|
||||||
"i18next-browser-languagedetector": "^7.2.0",
|
"i18next-browser-languagedetector": "^7.2.0",
|
||||||
|
"ogl": "^1.0.11",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-i18next": "^13.5.0",
|
"react-i18next": "^13.5.0",
|
||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "^6.30.3",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
"zustand": "^4.4.0"
|
"zustand": "^4.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
229
src/App.tsx
229
src/App.tsx
@@ -7,7 +7,6 @@ import PageLoader from './components/common/PageLoader';
|
|||||||
import { MaintenanceScreen, ChannelSubscriptionScreen } from './components/blocking';
|
import { MaintenanceScreen, ChannelSubscriptionScreen } from './components/blocking';
|
||||||
import { saveReturnUrl } from './utils/token';
|
import { saveReturnUrl } from './utils/token';
|
||||||
import { useAnalyticsCounters } from './hooks/useAnalyticsCounters';
|
import { useAnalyticsCounters } from './hooks/useAnalyticsCounters';
|
||||||
|
|
||||||
// Auth pages - load immediately (small)
|
// Auth pages - load immediately (small)
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import TelegramCallback from './pages/TelegramCallback';
|
import TelegramCallback from './pages/TelegramCallback';
|
||||||
@@ -31,22 +30,40 @@ const Wheel = lazy(() => import('./pages/Wheel'));
|
|||||||
// Admin pages - lazy load (only for admins)
|
// Admin pages - lazy load (only for admins)
|
||||||
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
|
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
|
||||||
const AdminTickets = lazy(() => import('./pages/AdminTickets'));
|
const AdminTickets = lazy(() => import('./pages/AdminTickets'));
|
||||||
|
const AdminTicketSettings = lazy(() => import('./pages/AdminTicketSettings'));
|
||||||
const AdminSettings = lazy(() => import('./pages/AdminSettings'));
|
const AdminSettings = lazy(() => import('./pages/AdminSettings'));
|
||||||
const AdminApps = lazy(() => import('./pages/AdminApps'));
|
const AdminApps = lazy(() => import('./pages/AdminApps'));
|
||||||
const AdminWheel = lazy(() => import('./pages/AdminWheel'));
|
const AdminWheel = lazy(() => import('./pages/AdminWheel'));
|
||||||
const AdminTariffs = lazy(() => import('./pages/AdminTariffs'));
|
const AdminTariffs = lazy(() => import('./pages/AdminTariffs'));
|
||||||
|
const AdminTariffCreate = lazy(() => import('./pages/AdminTariffCreate'));
|
||||||
const AdminServers = lazy(() => import('./pages/AdminServers'));
|
const AdminServers = lazy(() => import('./pages/AdminServers'));
|
||||||
|
const AdminServerEdit = lazy(() => import('./pages/AdminServerEdit'));
|
||||||
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));
|
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));
|
||||||
const AdminBanSystem = lazy(() => import('./pages/AdminBanSystem'));
|
const AdminBanSystem = lazy(() => import('./pages/AdminBanSystem'));
|
||||||
const AdminBroadcasts = lazy(() => import('./pages/AdminBroadcasts'));
|
const AdminBroadcasts = lazy(() => import('./pages/AdminBroadcasts'));
|
||||||
|
const AdminBroadcastCreate = lazy(() => import('./pages/AdminBroadcastCreate'));
|
||||||
const AdminPromocodes = lazy(() => import('./pages/AdminPromocodes'));
|
const AdminPromocodes = lazy(() => import('./pages/AdminPromocodes'));
|
||||||
|
const AdminPromocodeCreate = lazy(() => import('./pages/AdminPromocodeCreate'));
|
||||||
|
const AdminPromocodeStats = lazy(() => import('./pages/AdminPromocodeStats'));
|
||||||
|
const AdminPromoGroups = lazy(() => import('./pages/AdminPromoGroups'));
|
||||||
|
const AdminPromoGroupCreate = lazy(() => import('./pages/AdminPromoGroupCreate'));
|
||||||
const AdminCampaigns = lazy(() => import('./pages/AdminCampaigns'));
|
const AdminCampaigns = lazy(() => import('./pages/AdminCampaigns'));
|
||||||
|
const AdminCampaignCreate = lazy(() => import('./pages/AdminCampaignCreate'));
|
||||||
|
const AdminCampaignStats = lazy(() => import('./pages/AdminCampaignStats'));
|
||||||
|
const AdminCampaignEdit = lazy(() => import('./pages/AdminCampaignEdit'));
|
||||||
const AdminUsers = lazy(() => import('./pages/AdminUsers'));
|
const AdminUsers = lazy(() => import('./pages/AdminUsers'));
|
||||||
const AdminPayments = lazy(() => import('./pages/AdminPayments'));
|
const AdminPayments = lazy(() => import('./pages/AdminPayments'));
|
||||||
const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods'));
|
const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods'));
|
||||||
|
const AdminPaymentMethodEdit = lazy(() => import('./pages/AdminPaymentMethodEdit'));
|
||||||
const AdminPromoOffers = lazy(() => import('./pages/AdminPromoOffers'));
|
const AdminPromoOffers = lazy(() => import('./pages/AdminPromoOffers'));
|
||||||
|
const AdminPromoOfferTemplateEdit = lazy(() => import('./pages/AdminPromoOfferTemplateEdit'));
|
||||||
|
const AdminPromoOfferSend = lazy(() => import('./pages/AdminPromoOfferSend'));
|
||||||
const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave'));
|
const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave'));
|
||||||
|
const AdminRemnawaveSquadDetail = lazy(() => import('./pages/AdminRemnawaveSquadDetail'));
|
||||||
const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates'));
|
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 }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { isAuthenticated, isLoading } = useAuthStore();
|
const { isAuthenticated, isLoading } = useAuthStore();
|
||||||
@@ -245,6 +262,16 @@ function App() {
|
|||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/tickets/settings"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<AdminTicketSettings />
|
||||||
|
</LazyPage>
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/settings"
|
path="/admin/settings"
|
||||||
element={
|
element={
|
||||||
@@ -285,6 +312,26 @@ function App() {
|
|||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/tariffs/create"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<AdminTariffCreate />
|
||||||
|
</LazyPage>
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/tariffs/:id/edit"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<AdminTariffCreate />
|
||||||
|
</LazyPage>
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/servers"
|
path="/admin/servers"
|
||||||
element={
|
element={
|
||||||
@@ -295,6 +342,16 @@ function App() {
|
|||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/servers/:id/edit"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<AdminServerEdit />
|
||||||
|
</LazyPage>
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/dashboard"
|
path="/admin/dashboard"
|
||||||
element={
|
element={
|
||||||
@@ -325,6 +382,16 @@ function App() {
|
|||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/broadcasts/create"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<AdminBroadcastCreate />
|
||||||
|
</LazyPage>
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/promocodes"
|
path="/admin/promocodes"
|
||||||
element={
|
element={
|
||||||
@@ -335,6 +402,66 @@ function App() {
|
|||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/promocodes/create"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<AdminPromocodeCreate />
|
||||||
|
</LazyPage>
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/promocodes/:id/edit"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<AdminPromocodeCreate />
|
||||||
|
</LazyPage>
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/promocodes/:id/stats"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<AdminPromocodeStats />
|
||||||
|
</LazyPage>
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/promo-groups"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<AdminPromoGroups />
|
||||||
|
</LazyPage>
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/promo-groups/create"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<AdminPromoGroupCreate />
|
||||||
|
</LazyPage>
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/promo-groups/:id/edit"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<AdminPromoGroupCreate />
|
||||||
|
</LazyPage>
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/campaigns"
|
path="/admin/campaigns"
|
||||||
element={
|
element={
|
||||||
@@ -345,6 +472,36 @@ function App() {
|
|||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/campaigns/create"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<AdminCampaignCreate />
|
||||||
|
</LazyPage>
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/campaigns/:id/stats"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<AdminCampaignStats />
|
||||||
|
</LazyPage>
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/campaigns/:id/edit"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<AdminCampaignEdit />
|
||||||
|
</LazyPage>
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/users"
|
path="/admin/users"
|
||||||
element={
|
element={
|
||||||
@@ -375,6 +532,16 @@ function App() {
|
|||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/payment-methods/:methodId/edit"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<AdminPaymentMethodEdit />
|
||||||
|
</LazyPage>
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/promo-offers"
|
path="/admin/promo-offers"
|
||||||
element={
|
element={
|
||||||
@@ -385,6 +552,26 @@ function App() {
|
|||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/promo-offers/templates/:id/edit"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<AdminPromoOfferTemplateEdit />
|
||||||
|
</LazyPage>
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/promo-offers/send"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<AdminPromoOfferSend />
|
||||||
|
</LazyPage>
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/remnawave"
|
path="/admin/remnawave"
|
||||||
element={
|
element={
|
||||||
@@ -395,6 +582,16 @@ function App() {
|
|||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/remnawave/squads/:uuid"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<AdminRemnawaveSquadDetail />
|
||||||
|
</LazyPage>
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/email-templates"
|
path="/admin/email-templates"
|
||||||
element={
|
element={
|
||||||
@@ -405,6 +602,36 @@ function App() {
|
|||||||
</AdminRoute>
|
</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 */}
|
{/* Catch all */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
102
src/AppWithNavigator.tsx
Normal file
102
src/AppWithNavigator.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { BrowserRouter, Router } from 'react-router-dom';
|
||||||
|
import { useIntegration } from '@telegram-apps/react-router-integration';
|
||||||
|
import { initNavigator } from '@telegram-apps/sdk';
|
||||||
|
import App from './App';
|
||||||
|
import { PlatformProvider } from './platform/PlatformProvider';
|
||||||
|
import { ThemeColorsProvider } from './providers/ThemeColorsProvider';
|
||||||
|
import { WebSocketProvider } from './providers/WebSocketProvider';
|
||||||
|
import { ToastProvider } from './components/Toast';
|
||||||
|
import { TooltipProvider } from './components/primitives/Tooltip';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if running inside Telegram Mini App
|
||||||
|
* Uses multiple checks to reliably detect Telegram environment
|
||||||
|
*/
|
||||||
|
function isTelegramMiniApp(): boolean {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
|
||||||
|
const webApp = window.Telegram?.WebApp;
|
||||||
|
if (!webApp) return false;
|
||||||
|
|
||||||
|
// Check 1: initDataUnsafe should have user data in real Telegram
|
||||||
|
const hasUserData = webApp.initDataUnsafe?.user?.id !== undefined;
|
||||||
|
|
||||||
|
// Check 2: Platform should not be 'unknown' (which is default in browser)
|
||||||
|
const validPlatform = webApp.platform !== 'unknown' && webApp.platform !== '';
|
||||||
|
|
||||||
|
// Check 3: Version should be present (SDK loads in Telegram only)
|
||||||
|
const hasVersion = webApp.version !== undefined && webApp.version !== '';
|
||||||
|
|
||||||
|
return hasUserData || (validPlatform && hasVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component wrapper for Telegram navigator setup.
|
||||||
|
* Integrates Telegram Mini Apps navigator with React Router to provide
|
||||||
|
* automatic BackButton management based on navigation history.
|
||||||
|
* Falls back to BrowserRouter when not in Telegram Mini App.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Navigator-based router for Telegram Mini App
|
||||||
|
*/
|
||||||
|
function TelegramRouter({ children }: { children: React.ReactNode }) {
|
||||||
|
const navigator = useMemo(() => initNavigator('app-navigation-state', { hashMode: null }), []);
|
||||||
|
const [location, reactNavigator] = useIntegration(navigator);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
navigator.attach();
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
navigator.detach();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to detach navigator:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to attach navigator:', err);
|
||||||
|
}
|
||||||
|
}, [navigator]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Router location={location} navigator={reactNavigator}>
|
||||||
|
{children}
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppWithNavigator() {
|
||||||
|
const isTelegram = useMemo(() => {
|
||||||
|
const result = isTelegramMiniApp();
|
||||||
|
console.log('[AppWithNavigator] Platform detection:', {
|
||||||
|
isTelegram: result,
|
||||||
|
platform: window.Telegram?.WebApp?.platform,
|
||||||
|
hasUser: window.Telegram?.WebApp?.initDataUnsafe?.user?.id !== undefined,
|
||||||
|
version: window.Telegram?.WebApp?.version,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Common app content
|
||||||
|
const appContent = (
|
||||||
|
<PlatformProvider>
|
||||||
|
<ThemeColorsProvider>
|
||||||
|
<TooltipProvider>
|
||||||
|
<ToastProvider>
|
||||||
|
<WebSocketProvider>
|
||||||
|
<App />
|
||||||
|
</WebSocketProvider>
|
||||||
|
</ToastProvider>
|
||||||
|
</TooltipProvider>
|
||||||
|
</ThemeColorsProvider>
|
||||||
|
</PlatformProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use Telegram navigator in Mini App, BrowserRouter elsewhere
|
||||||
|
if (isTelegram) {
|
||||||
|
return <TelegramRouter>{appContent}</TelegramRouter>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <BrowserRouter>{appContent}</BrowserRouter>;
|
||||||
|
}
|
||||||
@@ -153,6 +153,20 @@ apiClient.interceptors.response.use(
|
|||||||
|
|
||||||
// Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала)
|
// Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала)
|
||||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
// Не обрабатываем 401 для авторизационных endpoints - пусть ошибка дойдет до компонента
|
||||||
|
const authEndpoints = [
|
||||||
|
'/cabinet/auth/email/login',
|
||||||
|
'/cabinet/auth/telegram',
|
||||||
|
'/cabinet/auth/telegram/widget',
|
||||||
|
];
|
||||||
|
const requestUrl = originalRequest.url || '';
|
||||||
|
const isAuthEndpoint = authEndpoints.some((endpoint) => requestUrl.includes(endpoint));
|
||||||
|
|
||||||
|
if (isAuthEndpoint) {
|
||||||
|
// Пробрасываем ошибку в компонент для показа сообщения пользователю
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
originalRequest._retry = true;
|
originalRequest._retry = true;
|
||||||
|
|
||||||
const newToken = await tokenRefreshManager.refreshAccessToken();
|
const newToken = await tokenRefreshManager.refreshAccessToken();
|
||||||
|
|||||||
@@ -101,7 +101,9 @@ export const promoApi = {
|
|||||||
|
|
||||||
// Deactivate current active promo discount
|
// Deactivate current active promo discount
|
||||||
deactivateDiscount: async (): Promise<DeactivateDiscountResponse> => {
|
deactivateDiscount: async (): Promise<DeactivateDiscountResponse> => {
|
||||||
const response = await apiClient.post<DeactivateDiscountResponse>('/cabinet/promocode/deactivate-discount');
|
const response = await apiClient.post<DeactivateDiscountResponse>(
|
||||||
|
'/cabinet/promocode/deactivate-discount',
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -236,4 +236,10 @@ export const tariffsApi = {
|
|||||||
const response = await apiClient.get('/cabinet/admin/tariffs/available-servers');
|
const response = await apiClient.get('/cabinet/admin/tariffs/available-servers');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Get available promo groups for selection
|
||||||
|
getAvailablePromoGroups: async (): Promise<{ id: number; name: string }[]> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/payment-methods/promo-groups');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { authApi } from '../api/auth';
|
import { authApi } from '../api/auth';
|
||||||
import { useAuthStore } from '../store/auth';
|
import { useAuthStore } from '../store/auth';
|
||||||
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
|
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
|
||||||
|
import { useBackButton } from '@/platform';
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
const CloseIcon = () => (
|
const CloseIcon = () => (
|
||||||
@@ -55,7 +56,7 @@ export default function ChangeEmailModal({ onClose, currentEmail }: ChangeEmailM
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { setUser } = useAuthStore();
|
const { setUser } = useAuthStore();
|
||||||
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp();
|
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp();
|
||||||
const isMobileScreen = useIsMobile();
|
const isMobileScreen = useIsMobile();
|
||||||
|
|
||||||
const emailInputRef = useRef<HTMLInputElement>(null);
|
const emailInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -87,16 +88,8 @@ export default function ChangeEmailModal({ onClose, currentEmail }: ChangeEmailM
|
|||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [handleClose]);
|
}, [handleClose]);
|
||||||
|
|
||||||
// Telegram back button
|
// Telegram back button - using platform hook
|
||||||
useEffect(() => {
|
useBackButton(handleClose);
|
||||||
if (!webApp?.BackButton) return;
|
|
||||||
webApp.BackButton.show();
|
|
||||||
webApp.BackButton.onClick(handleClose);
|
|
||||||
return () => {
|
|
||||||
webApp.BackButton.offClick(handleClose);
|
|
||||||
webApp.BackButton.hide();
|
|
||||||
};
|
|
||||||
}, [webApp, handleClose]);
|
|
||||||
|
|
||||||
// Scroll lock
|
// Scroll lock
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { subscriptionApi } from '../api/subscription';
|
import { subscriptionApi } from '../api/subscription';
|
||||||
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
|
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
|
||||||
|
import { useBackButton, useHaptic } from '@/platform';
|
||||||
import type { AppInfo, AppConfig, LocalizedText } from '../types';
|
import type { AppInfo, AppConfig, LocalizedText } from '../types';
|
||||||
|
|
||||||
interface ConnectionModalProps {
|
interface ConnectionModalProps {
|
||||||
@@ -156,14 +157,18 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
const [showAppSelector, setShowAppSelector] = useState(false);
|
const [showAppSelector, setShowAppSelector] = useState(false);
|
||||||
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
|
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
|
||||||
|
|
||||||
const { isTelegramWebApp, isFullscreen, safeAreaInset, contentSafeAreaInset, webApp } =
|
const { isTelegramWebApp, isFullscreen, safeAreaInset, contentSafeAreaInset } =
|
||||||
useTelegramWebApp();
|
useTelegramWebApp();
|
||||||
|
const { impact: hapticImpact } = useHaptic();
|
||||||
const isMobileScreen = useIsMobile();
|
const isMobileScreen = useIsMobile();
|
||||||
const isMobile = isMobileScreen;
|
const isMobile = isMobileScreen;
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Ref для хранения актуального обработчика BackButton (фикс мигания)
|
// Ref для хранения актуального обработчика BackButton (фикс мигания)
|
||||||
const backButtonHandlerRef = useRef<() => void>(() => {});
|
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
|
// Prevent scroll events from bubbling to parent/Telegram
|
||||||
const handleScrollContainerWheel = useCallback((e: React.WheelEvent) => {
|
const handleScrollContainerWheel = useCallback((e: React.WheelEvent) => {
|
||||||
@@ -237,23 +242,13 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
backButtonHandlerRef.current = showAppSelector ? handleBack : handleClose;
|
backButtonHandlerRef.current = showAppSelector ? handleBack : handleClose;
|
||||||
}, [showAppSelector, handleBack, handleClose]);
|
}, [showAppSelector, handleBack, handleClose]);
|
||||||
|
|
||||||
// Управление BackButton — эффект запускается только при mount/unmount
|
// BackButton using platform hook - always close/back, ref provides current handler
|
||||||
// Используем стабильный обработчик через ref, чтобы избежать мигания
|
const handleBackButton = useCallback(() => {
|
||||||
useEffect(() => {
|
hapticRef.current('light');
|
||||||
if (!webApp?.BackButton) return;
|
|
||||||
|
|
||||||
const stableHandler = () => {
|
|
||||||
backButtonHandlerRef.current();
|
backButtonHandlerRef.current();
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
webApp.BackButton.show();
|
useBackButton(handleBackButton);
|
||||||
webApp.BackButton.onClick(stableHandler);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
webApp.BackButton.offClick(stableHandler);
|
|
||||||
webApp.BackButton.hide();
|
|
||||||
};
|
|
||||||
}, [webApp]); // Только webApp в зависимостях!
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
@@ -453,12 +448,14 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
return (
|
return (
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
<div className="flex items-center gap-3 border-b border-dark-800 p-4">
|
<div className="flex items-center gap-3 border-b border-dark-800 p-4">
|
||||||
|
{!isTelegramWebApp && (
|
||||||
<button
|
<button
|
||||||
onClick={handleBack}
|
onClick={handleBack}
|
||||||
className="-ml-2 rounded-xl p-2 text-dark-300 hover:bg-dark-800"
|
className="-ml-2 rounded-xl p-2 text-dark-300 hover:bg-dark-800"
|
||||||
>
|
>
|
||||||
<BackIcon />
|
<BackIcon />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<h2 className="text-lg font-bold text-dark-100">
|
<h2 className="text-lg font-bold text-dark-100">
|
||||||
{t('subscription.connection.selectPlatform')}
|
{t('subscription.connection.selectPlatform')}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -525,12 +522,14 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
return (
|
return (
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
<div className="flex items-center gap-3 border-b border-dark-800 p-4">
|
<div className="flex items-center gap-3 border-b border-dark-800 p-4">
|
||||||
|
{!isTelegramWebApp && (
|
||||||
<button
|
<button
|
||||||
onClick={handleBack}
|
onClick={handleBack}
|
||||||
className="-ml-2 rounded-xl p-2 text-dark-300 hover:bg-dark-800"
|
className="-ml-2 rounded-xl p-2 text-dark-300 hover:bg-dark-800"
|
||||||
>
|
>
|
||||||
<BackIcon />
|
<BackIcon />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h2 className="text-lg font-bold text-dark-100">
|
<h2 className="text-lg font-bold text-dark-100">
|
||||||
{platformNames[selectedPlatform] || selectedPlatform}
|
{platformNames[selectedPlatform] || selectedPlatform}
|
||||||
@@ -614,12 +613,14 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
<div className="border-b border-dark-800 p-4">
|
<div className="border-b border-dark-800 p-4">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h2 className="text-lg font-bold text-dark-100">{t('subscription.connection.title')}</h2>
|
<h2 className="text-lg font-bold text-dark-100">{t('subscription.connection.title')}</h2>
|
||||||
|
{!isTelegramWebApp && (
|
||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="-mr-2 rounded-xl p-2 text-dark-400 hover:bg-dark-800"
|
className="-mr-2 rounded-xl p-2 text-dark-400 hover:bg-dark-800"
|
||||||
>
|
>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAppSelector(true)}
|
onClick={() => setShowAppSelector(true)}
|
||||||
|
|||||||
@@ -41,7 +41,11 @@ export default function LanguageSwitcher() {
|
|||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
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"
|
aria-label="Change language"
|
||||||
>
|
>
|
||||||
<span>{currentLang.flag}</span>
|
<span>{currentLang.flag}</span>
|
||||||
|
|||||||
@@ -1,54 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { promoApi, PromoOffer } from '../api/promo';
|
import { promoApi, PromoOffer } from '../api/promo';
|
||||||
|
import { ClockIcon, CheckIcon } from './icons';
|
||||||
// Icons
|
import { useTelegramSDK } from '../hooks/useTelegramSDK';
|
||||||
const GiftIcon = () => (
|
|
||||||
<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 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const ClockIcon = () => (
|
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const SparklesIcon = () => (
|
|
||||||
<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.09z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const CheckIcon = () => (
|
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const ServerIcon = () => (
|
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
const formatTimeLeft = (
|
const formatTimeLeft = (
|
||||||
@@ -81,9 +38,10 @@ const formatTimeLeft = (
|
|||||||
return `${minutes}${t('promo.time.minutes')}`;
|
return `${minutes}${t('promo.time.minutes')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getOfferIcon = (effectType: string) => {
|
const getOfferIcon = (effectType: string, discountPercent?: number | null) => {
|
||||||
if (effectType === 'test_access') return <ServerIcon />;
|
if (effectType === 'test_access') return <span className="text-2xl">🚀</span>;
|
||||||
return <SparklesIcon />;
|
if (discountPercent) return <span className="text-2xl">🏷️</span>;
|
||||||
|
return <span className="text-2xl">🎁</span>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getOfferTitle = (
|
const getOfferTitle = (
|
||||||
@@ -112,16 +70,166 @@ const getOfferDescription = (
|
|||||||
return t('promo.offers.activateDiscountHint');
|
return t('promo.offers.activateDiscountHint');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Icons for deactivation
|
||||||
|
const XCircleIcon = () => (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const WarningIcon = () => (
|
||||||
|
<svg
|
||||||
|
className="h-12 w-12"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ------- Confirmation Modal -------
|
||||||
|
|
||||||
|
interface DeactivateConfirmModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
discountPercent: number;
|
||||||
|
isDeactivating: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeactivateConfirmModal({
|
||||||
|
isOpen,
|
||||||
|
discountPercent,
|
||||||
|
isDeactivating,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: DeactivateConfirmModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Escape key to close
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && !isDeactivating) {
|
||||||
|
e.preventDefault();
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isOpen, isDeactivating, onCancel]);
|
||||||
|
|
||||||
|
// Scroll lock
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const modalContent = (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[100] flex items-center justify-center"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="deactivate-modal-title"
|
||||||
|
>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||||
|
onClick={isDeactivating ? undefined : onCancel}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div
|
||||||
|
className="relative mx-4 w-full max-w-sm overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-900 shadow-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Warning header */}
|
||||||
|
<div className="flex flex-col items-center bg-gradient-to-br from-warning-500/20 to-red-500/20 px-6 pb-6 pt-8">
|
||||||
|
<div className="mb-3 text-warning-400">
|
||||||
|
<WarningIcon />
|
||||||
|
</div>
|
||||||
|
<h2 id="deactivate-modal-title" className="text-center text-lg font-bold text-dark-100">
|
||||||
|
{t('promo.deactivate.confirmTitle')}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-center text-sm text-dark-300">
|
||||||
|
{t('promo.deactivate.confirmDescription', { percent: discountPercent })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning text */}
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
<div className="rounded-lg border border-warning-500/20 bg-warning-500/10 px-4 py-3">
|
||||||
|
<p className="text-center text-sm text-warning-400">{t('promo.deactivate.warning')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex gap-3 px-6 pb-6">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isDeactivating}
|
||||||
|
className="flex-1 rounded-xl bg-dark-800 py-3 font-semibold text-dark-300 transition-colors hover:bg-dark-700 hover:text-dark-100 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isDeactivating}
|
||||||
|
className="flex-1 rounded-xl bg-gradient-to-r from-red-500 to-red-600 py-3 font-semibold text-white shadow-lg shadow-red-500/25 transition-all hover:from-red-400 hover:to-red-500 active:from-red-600 active:to-red-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isDeactivating ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
|
{t('promo.deactivate.deactivating')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
t('promo.deactivate.confirm')
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
return createPortal(modalContent, document.body);
|
||||||
|
}
|
||||||
|
return modalContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------- Main Component -------
|
||||||
|
|
||||||
interface PromoOffersSectionProps {
|
interface PromoOffersSectionProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PromoOffersSection({ className = '' }: PromoOffersSectionProps) {
|
export default function PromoOffersSection({ className = '' }: PromoOffersSectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { isTelegramWebApp } = useTelegramSDK();
|
||||||
const [claimingId, setClaimingId] = useState<number | null>(null);
|
const [claimingId, setClaimingId] = useState<number | null>(null);
|
||||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||||
|
|
||||||
// Fetch available offers
|
// Fetch available offers
|
||||||
const { data: offers = [], isLoading: offersLoading } = useQuery({
|
const { data: offers = [], isLoading: offersLoading } = useQuery({
|
||||||
@@ -156,6 +264,26 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Deactivate discount mutation
|
||||||
|
const deactivateMutation = useMutation({
|
||||||
|
mutationFn: promoApi.clearActiveDiscount,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['active-discount'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['promo-offers'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
||||||
|
setShowConfirmModal(false);
|
||||||
|
setSuccessMessage(t('promo.deactivate.success'));
|
||||||
|
setTimeout(() => setSuccessMessage(null), 5000);
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
const axiosErr = error as { response?: { data?: { detail?: string } } };
|
||||||
|
setErrorMessage(axiosErr.response?.data?.detail || t('promo.deactivate.error'));
|
||||||
|
setShowConfirmModal(false);
|
||||||
|
setTimeout(() => setErrorMessage(null), 5000);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleClaim = (offerId: number) => {
|
const handleClaim = (offerId: number) => {
|
||||||
setClaimingId(offerId);
|
setClaimingId(offerId);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
@@ -163,6 +291,49 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
|||||||
claimMutation.mutate(offerId);
|
claimMutation.mutate(offerId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUseNow = () => {
|
||||||
|
navigate('/subscription', { state: { scrollToExtend: true } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeactivateClick = () => {
|
||||||
|
setErrorMessage(null);
|
||||||
|
setSuccessMessage(null);
|
||||||
|
|
||||||
|
// Use Telegram native popup in Mini App
|
||||||
|
if (isTelegramWebApp && window.Telegram?.WebApp?.showPopup) {
|
||||||
|
window.Telegram.WebApp.showPopup(
|
||||||
|
{
|
||||||
|
title: t('promo.deactivate.confirmTitle'),
|
||||||
|
message: t('promo.deactivate.confirmDescription', {
|
||||||
|
percent: activeDiscount?.discount_percent || 0,
|
||||||
|
}),
|
||||||
|
buttons: [
|
||||||
|
{ id: 'cancel', type: 'cancel' },
|
||||||
|
{ id: 'confirm', type: 'destructive', text: t('promo.deactivate.confirm') },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
(button_id: string) => {
|
||||||
|
if (button_id === 'confirm') {
|
||||||
|
deactivateMutation.mutate();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Fallback to modal for web
|
||||||
|
setShowConfirmModal(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDeactivate = () => {
|
||||||
|
deactivateMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseConfirmModal = () => {
|
||||||
|
if (!deactivateMutation.isPending) {
|
||||||
|
setShowConfirmModal(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Filter unclaimed and active offers
|
// Filter unclaimed and active offers
|
||||||
const availableOffers = offers.filter((o) => o.is_active && !o.is_claimed);
|
const availableOffers = offers.filter((o) => o.is_active && !o.is_claimed);
|
||||||
|
|
||||||
@@ -177,22 +348,24 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-4 ${className}`}>
|
<div className={`space-y-4 ${className}`}>
|
||||||
{/* Active Discount Banner */}
|
{/* Active Discount Banner with actions */}
|
||||||
{activeDiscount && activeDiscount.is_active && activeDiscount.discount_percent > 0 && (
|
{activeDiscount && activeDiscount.is_active && activeDiscount.discount_percent > 0 && (
|
||||||
<div className="card border-accent-500/30 bg-gradient-to-br from-accent-500/10 to-transparent">
|
<div className="card border-success-500/30 bg-gradient-to-br from-success-500/10 to-accent-500/5">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-accent-500/20 text-accent-400">
|
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-success-500/20 text-success-400">
|
||||||
<CheckIcon />
|
<span className="text-2xl">🏷️</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="mb-1 flex items-center gap-2">
|
<div className="mb-1 flex flex-wrap items-center gap-2">
|
||||||
<h3 className="font-semibold text-dark-100">
|
<h3 className="font-semibold text-dark-100">
|
||||||
{t('promo.offers.discountActiveTitle', {
|
{t('promo.offers.discountActiveTitle', {
|
||||||
percent: activeDiscount.discount_percent,
|
percent: activeDiscount.discount_percent,
|
||||||
})}
|
})}
|
||||||
</h3>
|
</h3>
|
||||||
<span className="rounded bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
|
<span className="rounded bg-success-500/20 px-2 py-0.5 text-xs font-bold text-success-400">
|
||||||
{t('promo.offers.statusActive')}
|
-{activeDiscount.discount_percent}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-sm text-dark-400">
|
<div className="flex items-center gap-4 text-sm text-dark-400">
|
||||||
@@ -209,6 +382,24 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<button
|
||||||
|
onClick={handleUseNow}
|
||||||
|
className="flex-1 rounded-xl bg-gradient-to-r from-success-500 to-success-600 px-4 py-2.5 font-semibold text-white shadow-lg shadow-success-500/25 transition-all hover:from-success-400 hover:to-success-500 active:from-success-600 active:to-success-700"
|
||||||
|
>
|
||||||
|
{t('promo.useNow')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDeactivateClick}
|
||||||
|
className="flex items-center justify-center gap-1.5 rounded-xl border border-dark-600/50 bg-dark-900/50 px-4 py-2.5 text-sm text-dark-400 transition-colors hover:border-red-500/30 hover:bg-red-500/10 hover:text-red-400"
|
||||||
|
>
|
||||||
|
<XCircleIcon />
|
||||||
|
<span>{t('promo.deactivate.button')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -235,8 +426,8 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
|||||||
className="card border-orange-500/30 bg-gradient-to-br from-orange-500/5 to-transparent transition-colors hover:border-orange-500/50"
|
className="card border-orange-500/30 bg-gradient-to-br from-orange-500/5 to-transparent transition-colors hover:border-orange-500/50"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-orange-500/20 text-orange-400">
|
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-orange-500/30 to-amber-500/20">
|
||||||
{getOfferIcon(offer.effect_type)}
|
{getOfferIcon(offer.effect_type, offer.discount_percent)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="mb-1 flex items-center gap-2">
|
<div className="mb-1 flex items-center gap-2">
|
||||||
@@ -248,7 +439,7 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-3 text-sm text-dark-400">{getOfferDescription(offer, t)}</p>
|
<p className="mb-3 text-sm text-dark-400">{getOfferDescription(offer, t)}</p>
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex items-center gap-1 text-xs text-dark-500">
|
<div className="flex items-center gap-1 text-xs text-dark-500">
|
||||||
<ClockIcon />
|
<ClockIcon />
|
||||||
<span>
|
<span>
|
||||||
@@ -258,8 +449,10 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleClaim(offer.id)}
|
onClick={() => handleClaim(offer.id)}
|
||||||
disabled={claimingId === offer.id}
|
disabled={claimingId === offer.id}
|
||||||
className="flex items-center gap-2 rounded-lg bg-orange-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-orange-600 disabled:cursor-not-allowed disabled:opacity-50"
|
className="group relative flex w-full items-center justify-center gap-2 overflow-hidden rounded-lg bg-gradient-to-r from-orange-500 to-amber-500 px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-orange-500/25 transition-all hover:scale-105 hover:shadow-xl hover:shadow-orange-500/30 active:scale-100 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 sm:w-auto"
|
||||||
>
|
>
|
||||||
|
{/* Shimmer effect */}
|
||||||
|
<span className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/20 to-transparent transition-transform duration-1000 group-hover:translate-x-full" />
|
||||||
{claimingId === offer.id ? (
|
{claimingId === offer.id ? (
|
||||||
<>
|
<>
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||||
@@ -267,7 +460,7 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<GiftIcon />
|
<span className="text-lg">🎁</span>
|
||||||
<span>{t('promo.offers.activate')}</span>
|
<span>{t('promo.offers.activate')}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -292,6 +485,17 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Deactivation Confirmation Modal */}
|
||||||
|
{activeDiscount && (
|
||||||
|
<DeactivateConfirmModal
|
||||||
|
isOpen={showConfirmModal}
|
||||||
|
discountPercent={activeDiscount.discount_percent || 0}
|
||||||
|
isDeactivating={deactivateMutation.isPending}
|
||||||
|
onConfirm={handleConfirmDeactivate}
|
||||||
|
onCancel={handleCloseConfirmModal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { useSuccessNotification } from '../store/successNotification';
|
import { useSuccessNotification } from '../store/successNotification';
|
||||||
import { useCurrency } from '../hooks/useCurrency';
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
|
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
|
||||||
|
import { useBackButton, useHaptic } from '@/platform';
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
const CheckCircleIcon = () => (
|
const CheckCircleIcon = () => (
|
||||||
@@ -79,7 +80,8 @@ export default function SuccessNotificationModal() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isOpen, data, hide } = useSuccessNotification();
|
const { isOpen, data, hide } = useSuccessNotification();
|
||||||
const { formatAmount, currencySymbol } = useCurrency();
|
const { formatAmount, currencySymbol } = useCurrency();
|
||||||
const { webApp, safeAreaInset, contentSafeAreaInset, isTelegramWebApp } = useTelegramWebApp();
|
const { safeAreaInset, contentSafeAreaInset, isTelegramWebApp } = useTelegramWebApp();
|
||||||
|
const haptic = useHaptic();
|
||||||
|
|
||||||
const safeBottom = isTelegramWebApp
|
const safeBottom = isTelegramWebApp
|
||||||
? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom)
|
? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom)
|
||||||
@@ -104,18 +106,15 @@ export default function SuccessNotificationModal() {
|
|||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [isOpen, handleClose]);
|
}, [isOpen, handleClose]);
|
||||||
|
|
||||||
// Telegram back button
|
// Telegram back button - using platform hook
|
||||||
|
useBackButton(isOpen ? handleClose : null);
|
||||||
|
|
||||||
|
// Haptic feedback on open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen || !webApp?.BackButton) return;
|
if (isOpen) {
|
||||||
|
haptic.notification('success');
|
||||||
webApp.BackButton.show();
|
}
|
||||||
webApp.BackButton.onClick(handleClose);
|
}, [isOpen, haptic]);
|
||||||
|
|
||||||
return () => {
|
|
||||||
webApp.BackButton.offClick(handleClose);
|
|
||||||
webApp.BackButton.hide();
|
|
||||||
};
|
|
||||||
}, [isOpen, webApp, handleClose]);
|
|
||||||
|
|
||||||
// Scroll lock
|
// Scroll lock
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -241,7 +241,11 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
|
|||||||
{/* Bell button */}
|
{/* Bell button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
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')}
|
title={t('notifications.ticketNotifications', 'Ticket notifications')}
|
||||||
>
|
>
|
||||||
<BellIcon />
|
<BellIcon />
|
||||||
|
|||||||
@@ -97,28 +97,32 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
|
|||||||
|
|
||||||
const typeStyles = {
|
const typeStyles = {
|
||||||
success: {
|
success: {
|
||||||
bg: 'bg-gradient-to-r from-success-500/20 to-success-600/10',
|
bg: 'bg-dark-800',
|
||||||
border: 'border-success-500/30',
|
accent: 'bg-gradient-to-r from-success-500/30 to-transparent',
|
||||||
|
border: 'border-success-500/50',
|
||||||
icon: 'text-success-400',
|
icon: 'text-success-400',
|
||||||
iconBg: 'bg-success-500/20',
|
iconBg: 'bg-success-500/30',
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
bg: 'bg-gradient-to-r from-error-500/20 to-error-600/10',
|
bg: 'bg-dark-800',
|
||||||
border: 'border-error-500/30',
|
accent: 'bg-gradient-to-r from-error-500/30 to-transparent',
|
||||||
|
border: 'border-error-500/50',
|
||||||
icon: 'text-error-400',
|
icon: 'text-error-400',
|
||||||
iconBg: 'bg-error-500/20',
|
iconBg: 'bg-error-500/30',
|
||||||
},
|
},
|
||||||
warning: {
|
warning: {
|
||||||
bg: 'bg-gradient-to-r from-warning-500/20 to-warning-600/10',
|
bg: 'bg-dark-800',
|
||||||
border: 'border-warning-500/30',
|
accent: 'bg-gradient-to-r from-warning-500/30 to-transparent',
|
||||||
|
border: 'border-warning-500/50',
|
||||||
icon: 'text-warning-400',
|
icon: 'text-warning-400',
|
||||||
iconBg: 'bg-warning-500/20',
|
iconBg: 'bg-warning-500/30',
|
||||||
},
|
},
|
||||||
info: {
|
info: {
|
||||||
bg: 'bg-gradient-to-r from-accent-500/20 to-accent-600/10',
|
bg: 'bg-dark-800',
|
||||||
border: 'border-accent-500/30',
|
accent: 'bg-gradient-to-r from-accent-500/30 to-transparent',
|
||||||
|
border: 'border-accent-500/50',
|
||||||
icon: 'text-accent-400',
|
icon: 'text-accent-400',
|
||||||
iconBg: 'bg-accent-500/20',
|
iconBg: 'bg-accent-500/30',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -181,11 +185,11 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`pointer-events-auto w-80 sm:w-96 ${style.bg} border backdrop-blur-xl ${style.border} animate-slide-in-right overflow-hidden rounded-2xl shadow-2xl shadow-black/20 ${toast.onClick ? 'cursor-pointer hover:scale-[1.02] active:scale-[0.98]' : ''} transition-transform duration-200`}
|
className={`pointer-events-auto w-80 sm:w-96 ${style.bg} border ${style.border} animate-slide-in-right overflow-hidden rounded-2xl shadow-2xl shadow-black/40 ${toast.onClick ? 'cursor-pointer hover:scale-[1.02] active:scale-[0.98]' : ''} transition-transform duration-200`}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
{/* Glow effect */}
|
{/* Color accent */}
|
||||||
<div className={`absolute inset-0 ${style.bg} opacity-50 blur-xl`} />
|
<div className={`absolute inset-0 ${style.accent}`} />
|
||||||
|
|
||||||
<div className="relative p-4">
|
<div className="relative p-4">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useCurrency } from '../hooks/useCurrency';
|
|||||||
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
|
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
|
||||||
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit';
|
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit';
|
||||||
import { useCloseOnSuccessNotification } from '../store/successNotification';
|
import { useCloseOnSuccessNotification } from '../store/successNotification';
|
||||||
|
import { useBackButton, useMainButton, useHaptic, usePlatform } from '@/platform';
|
||||||
import type { PaymentMethod } from '../types';
|
import type { PaymentMethod } from '../types';
|
||||||
import BentoCard from './ui/BentoCard';
|
import BentoCard from './ui/BentoCard';
|
||||||
|
|
||||||
@@ -116,7 +117,9 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } =
|
const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } =
|
||||||
useCurrency();
|
useCurrency();
|
||||||
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp();
|
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp();
|
||||||
|
const { openInvoice } = usePlatform();
|
||||||
|
const haptic = useHaptic();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const isMobileScreen = useIsMobile();
|
const isMobileScreen = useIsMobile();
|
||||||
|
|
||||||
@@ -160,16 +163,8 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [handleClose]);
|
}, [handleClose]);
|
||||||
|
|
||||||
// Telegram back button (Android)
|
// Telegram back button - using platform hook
|
||||||
useEffect(() => {
|
useBackButton(handleClose);
|
||||||
if (!webApp?.BackButton) return;
|
|
||||||
webApp.BackButton.show();
|
|
||||||
webApp.BackButton.onClick(handleClose);
|
|
||||||
return () => {
|
|
||||||
webApp.BackButton.offClick(handleClose);
|
|
||||||
webApp.BackButton.hide();
|
|
||||||
};
|
|
||||||
}, [webApp, handleClose]);
|
|
||||||
|
|
||||||
// Scroll lock
|
// Scroll lock
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -205,35 +200,29 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
|
|
||||||
const starsPaymentMutation = useMutation({
|
const starsPaymentMutation = useMutation({
|
||||||
mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks),
|
mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks),
|
||||||
onSuccess: (data) => {
|
onSuccess: async (data) => {
|
||||||
const webApp = window.Telegram?.WebApp;
|
|
||||||
if (!data.invoice_url) {
|
if (!data.invoice_url) {
|
||||||
setError(t('balance.errors.noPaymentLink'));
|
setError(t('balance.errors.noPaymentLink'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// openInvoice requires WebApp version 6.1+
|
|
||||||
const supportsInvoice =
|
|
||||||
webApp?.openInvoice && webApp?.isVersionAtLeast && webApp.isVersionAtLeast('6.1');
|
|
||||||
if (supportsInvoice) {
|
|
||||||
try {
|
try {
|
||||||
webApp.openInvoice(data.invoice_url, (status) => {
|
// Use platform-agnostic invoice opening
|
||||||
|
const status = await openInvoice(data.invoice_url);
|
||||||
if (status === 'paid') {
|
if (status === 'paid') {
|
||||||
|
haptic.notification('success');
|
||||||
setError(null);
|
setError(null);
|
||||||
onClose();
|
onClose();
|
||||||
} else if (status === 'failed') {
|
} else if (status === 'failed') {
|
||||||
|
haptic.notification('error');
|
||||||
setError(t('wheel.starsPaymentFailed'));
|
setError(t('wheel.starsPaymentFailed'));
|
||||||
}
|
}
|
||||||
});
|
// 'pending' and 'cancelled' just close without action
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(t('balance.errors.generic', { details: String(e) }));
|
setError(t('balance.errors.generic', { details: String(e) }));
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Fallback: open invoice URL in Telegram (browser or unsupported WebApp version)
|
|
||||||
window.open(data.invoice_url, '_blank', 'noopener,noreferrer');
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
|
haptic.notification('error');
|
||||||
const axiosError = err as { response?: { data?: { detail?: string }; status?: number } };
|
const axiosError = err as { response?: { data?: { detail?: string }; status?: number } };
|
||||||
setError(axiosError?.response?.data?.detail || t('balance.errors.invoiceFailed'));
|
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);
|
: convertAmount(rub).toFixed(currencyDecimals);
|
||||||
const isPending = topUpMutation.isPending || starsPaymentMutation.isPending;
|
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 () => {
|
const handleCopyUrl = async () => {
|
||||||
if (!paymentUrl) return;
|
if (!paymentUrl) return;
|
||||||
try {
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/components/admin/AdminLayout.tsx
Normal file
14
src/components/admin/AdminLayout.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface AdminLayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AdminLayout - wrapper for all admin pages.
|
||||||
|
* Animations removed to prevent black flash during transitions.
|
||||||
|
*/
|
||||||
|
export function AdminLayout({ children, className }: AdminLayoutProps) {
|
||||||
|
return <div className={className}>{children}</div>;
|
||||||
|
}
|
||||||
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,23 +1,112 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { SettingDefinition } from '../../api/adminSettings';
|
||||||
import { SearchIcon, CloseIcon } from './icons';
|
import { SearchIcon, CloseIcon } from './icons';
|
||||||
|
import { formatSettingKey } from './utils';
|
||||||
|
|
||||||
interface SettingsSearchProps {
|
interface SettingsSearchProps {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
setSearchQuery: (query: string) => void;
|
setSearchQuery: (query: string) => void;
|
||||||
resultsCount?: number;
|
resultsCount?: number;
|
||||||
|
allSettings?: SettingDefinition[];
|
||||||
|
onSelectSetting?: (setting: SettingDefinition) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsSearch({ searchQuery, setSearchQuery }: SettingsSearchProps) {
|
export function SettingsSearch({
|
||||||
|
searchQuery,
|
||||||
|
setSearchQuery,
|
||||||
|
allSettings,
|
||||||
|
onSelectSetting,
|
||||||
|
}: SettingsSearchProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Filter settings for autocomplete
|
||||||
|
const suggestions =
|
||||||
|
searchQuery.trim() && allSettings
|
||||||
|
? allSettings
|
||||||
|
.filter((s) => {
|
||||||
|
const q = searchQuery.toLowerCase().trim();
|
||||||
|
if (s.key.toLowerCase().includes(q)) return true;
|
||||||
|
if (s.name?.toLowerCase().includes(q)) return true;
|
||||||
|
const formattedKey = formatSettingKey(s.name || s.key);
|
||||||
|
const translatedName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
|
||||||
|
if (translatedName.toLowerCase().includes(q)) return true;
|
||||||
|
if (s.hint?.description?.toLowerCase().includes(q)) return true;
|
||||||
|
const categoryLabel = t(`admin.settings.categories.${s.category.key}`, s.category.key);
|
||||||
|
if (categoryLabel.toLowerCase().includes(q)) return true;
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
.slice(0, 8)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Close on click outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset highlighted index when suggestions change
|
||||||
|
useEffect(() => {
|
||||||
|
setHighlightedIndex(0);
|
||||||
|
}, [suggestions.length, searchQuery]);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (!isOpen || suggestions.length === 0) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightedIndex((i) => (i + 1) % suggestions.length);
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightedIndex((i) => (i - 1 + suggestions.length) % suggestions.length);
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
if (suggestions[highlightedIndex]) {
|
||||||
|
handleSelect(suggestions[highlightedIndex]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
setIsOpen(false);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (setting: SettingDefinition) => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearchQuery(setting.name || setting.key);
|
||||||
|
onSelectSetting?.(setting);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSettingDisplayName = (setting: SettingDefinition) => {
|
||||||
|
const formattedKey = formatSettingKey(setting.name || setting.key);
|
||||||
|
return t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div ref={containerRef} className="relative hidden sm:block">
|
||||||
{/* Search - desktop */}
|
|
||||||
<div className="relative hidden sm:block">
|
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setIsOpen(true)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={t('admin.settings.searchPlaceholder')}
|
placeholder={t('admin.settings.searchPlaceholder')}
|
||||||
className="w-48 rounded-xl border border-dark-700 bg-dark-800 py-2 pl-10 pr-10 text-sm text-dark-100 placeholder-dark-500 focus:border-accent-500 focus:outline-none lg:w-64"
|
className="w-48 rounded-xl border border-dark-700 bg-dark-800 py-2 pl-10 pr-10 text-sm text-dark-100 placeholder-dark-500 focus:border-accent-500 focus:outline-none lg:w-64"
|
||||||
/>
|
/>
|
||||||
@@ -26,29 +115,108 @@ export function SettingsSearch({ searchQuery, setSearchQuery }: SettingsSearchPr
|
|||||||
</div>
|
</div>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearchQuery('')}
|
onClick={() => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 transition-colors hover:text-dark-300"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 transition-colors hover:text-dark-300"
|
||||||
>
|
>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Autocomplete dropdown */}
|
||||||
|
{isOpen && suggestions.length > 0 && (
|
||||||
|
<div className="absolute right-0 top-full z-50 mt-1 max-h-80 w-80 overflow-y-auto rounded-xl border border-dark-700 bg-dark-800 py-1 shadow-xl">
|
||||||
|
{suggestions.map((setting, index) => (
|
||||||
|
<button
|
||||||
|
key={setting.key}
|
||||||
|
onClick={() => handleSelect(setting)}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(index)}
|
||||||
|
className={`flex w-full flex-col gap-0.5 px-3 py-2 text-left transition-colors ${
|
||||||
|
index === highlightedIndex ? 'bg-accent-500/20' : 'hover:bg-dark-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="truncate text-sm font-medium text-dark-100">
|
||||||
|
{getSettingDisplayName(setting)}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs text-dark-500">
|
||||||
|
{t(`admin.settings.categories.${setting.category.key}`, setting.category.key)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsSearchMobile({
|
export function SettingsSearchMobile({
|
||||||
searchQuery,
|
searchQuery,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
|
allSettings,
|
||||||
|
onSelectSetting,
|
||||||
}: Omit<SettingsSearchProps, 'resultsCount'>) {
|
}: Omit<SettingsSearchProps, 'resultsCount'>) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Filter settings for autocomplete
|
||||||
|
const suggestions =
|
||||||
|
searchQuery.trim() && allSettings
|
||||||
|
? allSettings
|
||||||
|
.filter((s) => {
|
||||||
|
const q = searchQuery.toLowerCase().trim();
|
||||||
|
if (s.key.toLowerCase().includes(q)) return true;
|
||||||
|
if (s.name?.toLowerCase().includes(q)) return true;
|
||||||
|
const formattedKey = formatSettingKey(s.name || s.key);
|
||||||
|
const translatedName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
|
||||||
|
if (translatedName.toLowerCase().includes(q)) return true;
|
||||||
|
if (s.hint?.description?.toLowerCase().includes(q)) return true;
|
||||||
|
const categoryLabel = t(`admin.settings.categories.${s.category.key}`, s.category.key);
|
||||||
|
if (categoryLabel.toLowerCase().includes(q)) return true;
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
.slice(0, 8)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Close on click outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHighlightedIndex(0);
|
||||||
|
}, [suggestions.length, searchQuery]);
|
||||||
|
|
||||||
|
const handleSelect = (setting: SettingDefinition) => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearchQuery(setting.name || setting.key);
|
||||||
|
onSelectSetting?.(setting);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSettingDisplayName = (setting: SettingDefinition) => {
|
||||||
|
const formattedKey = formatSettingKey(setting.name || setting.key);
|
||||||
|
return t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative mt-3 sm:hidden">
|
<div ref={containerRef} className="relative mt-3 sm:hidden">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setIsOpen(true)}
|
||||||
placeholder={t('admin.settings.searchPlaceholder')}
|
placeholder={t('admin.settings.searchPlaceholder')}
|
||||||
className="w-full rounded-xl border border-dark-700 bg-dark-800 py-2 pl-10 pr-10 text-sm text-dark-100 placeholder-dark-500 focus:border-accent-500 focus:outline-none"
|
className="w-full rounded-xl border border-dark-700 bg-dark-800 py-2 pl-10 pr-10 text-sm text-dark-100 placeholder-dark-500 focus:border-accent-500 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
@@ -57,12 +225,37 @@ export function SettingsSearchMobile({
|
|||||||
</div>
|
</div>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearchQuery('')}
|
onClick={() => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 transition-colors hover:text-dark-300"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 transition-colors hover:text-dark-300"
|
||||||
>
|
>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Autocomplete dropdown */}
|
||||||
|
{isOpen && suggestions.length > 0 && (
|
||||||
|
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-64 overflow-y-auto rounded-xl border border-dark-700 bg-dark-800 py-1 shadow-xl">
|
||||||
|
{suggestions.map((setting, index) => (
|
||||||
|
<button
|
||||||
|
key={setting.key}
|
||||||
|
onClick={() => handleSelect(setting)}
|
||||||
|
className={`flex w-full flex-col gap-0.5 px-3 py-2 text-left transition-colors ${
|
||||||
|
index === highlightedIndex ? 'bg-accent-500/20' : 'hover:bg-dark-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="truncate text-sm font-medium text-dark-100">
|
||||||
|
{getSettingDisplayName(setting)}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs text-dark-500">
|
||||||
|
{t(`admin.settings.categories.${setting.category.key}`, setting.category.key)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom';
|
import { AdminBackButton, StarIcon, CloseIcon, MENU_SECTIONS } from './index';
|
||||||
import { BackIcon, StarIcon, CloseIcon, MENU_SECTIONS } from './index';
|
|
||||||
|
|
||||||
interface SettingsSidebarProps {
|
interface SettingsSidebarProps {
|
||||||
activeSection: string;
|
activeSection: string;
|
||||||
@@ -26,12 +25,7 @@ export function SettingsSidebar({
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="border-b border-dark-700/50 p-4">
|
<div className="border-b border-dark-700/50 p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link
|
<AdminBackButton className="rounded-xl bg-dark-800 p-2 transition-colors hover:bg-dark-700" />
|
||||||
to="/admin"
|
|
||||||
className="rounded-xl bg-dark-800 p-2 transition-colors hover:bg-dark-700"
|
|
||||||
>
|
|
||||||
<BackIcon />
|
|
||||||
</Link>
|
|
||||||
<h1 className="text-lg font-bold text-dark-100">{t('admin.settings.title')}</h1>
|
<h1 className="text-lg font-bold text-dark-100">{t('admin.settings.title')}</h1>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
|||||||
@@ -1,141 +1,18 @@
|
|||||||
// Admin Settings Icons
|
// Re-export from centralized icons
|
||||||
|
export {
|
||||||
export const BackIcon = () => (
|
BackIcon,
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
SearchIcon,
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
StarIcon,
|
||||||
</svg>
|
ChevronDownIcon,
|
||||||
);
|
UploadIcon,
|
||||||
|
TrashIcon,
|
||||||
export const SearchIcon = () => (
|
PencilIcon,
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
RefreshIcon,
|
||||||
<path
|
LockIcon,
|
||||||
strokeLinecap="round"
|
CheckIcon,
|
||||||
strokeLinejoin="round"
|
CloseIcon,
|
||||||
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
SunIcon,
|
||||||
/>
|
MoonIcon,
|
||||||
</svg>
|
MenuIcon,
|
||||||
);
|
EditIcon,
|
||||||
|
} from '../icons';
|
||||||
export const StarIcon = ({ filled }: { filled?: boolean }) => (
|
|
||||||
<svg
|
|
||||||
className="h-5 w-5"
|
|
||||||
fill={filled ? 'currentColor' : 'none'}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const ChevronDownIcon = () => (
|
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const UploadIcon = () => (
|
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<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.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const TrashIcon = () => (
|
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const PencilIcon = () => (
|
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const RefreshIcon = () => (
|
|
||||||
<svg className="h-4 w-4" 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>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const LockIcon = () => (
|
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const CheckIcon = () => (
|
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const CloseIcon = () => (
|
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const SunIcon = () => (
|
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const MoonIcon = () => (
|
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const MenuIcon = () => (
|
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const EditIcon = () => (
|
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// Components
|
// Components
|
||||||
|
export * from './AdminBackButton';
|
||||||
|
export * from './AdminLayout';
|
||||||
export * from './icons';
|
export * from './icons';
|
||||||
export * from './Toggle';
|
export * from './Toggle';
|
||||||
export * from './SettingInput';
|
export * from './SettingInput';
|
||||||
@@ -9,6 +11,7 @@ export * from './ThemeTab';
|
|||||||
export * from './FavoritesTab';
|
export * from './FavoritesTab';
|
||||||
export * from './SettingsTab';
|
export * from './SettingsTab';
|
||||||
export * from './SettingsSidebar';
|
export * from './SettingsSidebar';
|
||||||
|
export * from './SettingsMobileTabs';
|
||||||
export * from './SettingsSearch';
|
export * from './SettingsSearch';
|
||||||
|
|
||||||
// Constants and utils
|
// Constants and utils
|
||||||
|
|||||||
@@ -3,11 +3,10 @@ interface PageLoaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PageLoader({ variant = 'dark' }: PageLoaderProps) {
|
export default function PageLoader({ variant = 'dark' }: PageLoaderProps) {
|
||||||
const bgClass = variant === 'dark' ? 'bg-dark-950' : 'bg-gray-50';
|
|
||||||
const spinnerColor = variant === 'dark' ? 'border-accent-500' : 'border-blue-500';
|
const spinnerColor = variant === 'dark' ? 'border-accent-500' : 'border-blue-500';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex min-h-screen items-center justify-center ${bgClass}`}>
|
<div className="flex min-h-[50vh] items-center justify-center">
|
||||||
<div
|
<div
|
||||||
className={`h-10 w-10 border-[3px] ${spinnerColor} animate-spin rounded-full border-t-transparent`}
|
className={`h-10 w-10 border-[3px] ${spinnerColor} animate-spin rounded-full border-t-transparent`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
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-[var(--bento-radius)]',
|
||||||
|
'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';
|
||||||
675
src/components/icons/index.tsx
Normal file
675
src/components/icons/index.tsx
Normal file
@@ -0,0 +1,675 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface IconProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation & Layout
|
||||||
|
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 BackIcon = ({ className }: IconProps) => (
|
||||||
|
<svg
|
||||||
|
className={cn('h-5 w-5', className)}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const MenuIcon = ({ 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.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CloseIcon = ({ 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="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ChevronDownIcon = ({ className }: IconProps) => (
|
||||||
|
<svg
|
||||||
|
className={cn('h-5 w-5', 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>
|
||||||
|
);
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
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 EditIcon = ({ className }: IconProps) => (
|
||||||
|
<svg
|
||||||
|
className={cn('h-4 w-4', className)}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PencilIcon = ({ className }: IconProps) => (
|
||||||
|
<svg
|
||||||
|
className={cn('h-4 w-4', className)}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TrashIcon = ({ className }: IconProps) => (
|
||||||
|
<svg
|
||||||
|
className={cn('h-5 w-5', className)}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const UploadIcon = ({ className }: IconProps) => (
|
||||||
|
<svg
|
||||||
|
className={cn('h-5 w-5', className)}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<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.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
|
||||||
|
/>
|
||||||
|
</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 RefreshIcon = ({
|
||||||
|
className,
|
||||||
|
spinning = false,
|
||||||
|
}: IconProps & { spinning?: boolean }) => (
|
||||||
|
<svg
|
||||||
|
className={cn('h-4 w-4', spinning && 'animate-spin', 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>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SyncIcon = ({ className }: IconProps) => (
|
||||||
|
<svg
|
||||||
|
className={cn('h-5 w-5', 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>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Status
|
||||||
|
export const CheckIcon = ({ className }: IconProps) => (
|
||||||
|
<svg
|
||||||
|
className={cn('h-4 w-4', className)}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const XIcon = ({ className }: IconProps) => (
|
||||||
|
<svg
|
||||||
|
className={cn('h-4 w-4', className)}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const LockIcon = ({ className }: IconProps) => (
|
||||||
|
<svg
|
||||||
|
className={cn('h-4 w-4', className)}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
|
||||||
|
// User & People
|
||||||
|
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 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 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>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Theme
|
||||||
|
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 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>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Features & Content
|
||||||
|
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 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 GiftIcon = ({ className }: IconProps) => (
|
||||||
|
<svg
|
||||||
|
className={cn('h-4 w-4', className)}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ClockIcon = ({ 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 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const StarIcon = ({ className, filled }: IconProps & { filled?: boolean }) => (
|
||||||
|
<svg
|
||||||
|
className={cn('h-5 w-5', className)}
|
||||||
|
fill={filled ? 'currentColor' : 'none'}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z"
|
||||||
|
/>
|
||||||
|
</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 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 ServerIcon = ({ 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.75 17.25v-.228a4.5 4.5 0 00-.12-1.03l-2.268-9.64a3.375 3.375 0 00-3.285-2.602H7.923a3.375 3.375 0 00-3.285 2.602l-2.268 9.64a4.5 4.5 0 00-.12 1.03v.228m19.5 0a3 3 0 01-3 3H5.25a3 3 0 01-3-3m19.5 0a3 3 0 00-3-3H5.25a3 3 0 00-3 3m16.5 0h.008v.008h-.008v-.008zm-3 0h.008v.008h-.008v-.008z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CampaignIcon = ({ 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="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const RemnawaveIcon = ({ className }: IconProps) => (
|
||||||
|
<svg
|
||||||
|
className={cn('h-5 w-5', className)}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M8 1a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-1.5 0V1.75A.75.75 0 0 1 8 1Zm6 2a.75.75 0 0 1 .75.75v8.5a.75.75 0 0 1-1.5 0v-8.5A.75.75 0 0 1 14 3ZM5 4a.75.75 0 0 1 .75.75v6.5a.75.75 0 0 1-1.5 0v-6.5A.75.75 0 0 1 5 4Zm6 1a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 11 5ZM2 6a.75.75 0 0 1 .75.75v2.5a.75.75 0 0 1-1.5 0v-2.5A.75.75 0 0 1 2 6Z"
|
||||||
|
fill="currentColor"
|
||||||
|
fillRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ChartIcon = ({ className }: IconProps) => (
|
||||||
|
<svg
|
||||||
|
className={cn('h-4 w-4', className)}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const GlobeIcon = ({ 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 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PlayIcon = ({ className }: IconProps) => (
|
||||||
|
<svg
|
||||||
|
className={cn('h-4 w-4', className)}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const StopIcon = ({ className }: IconProps) => (
|
||||||
|
<svg
|
||||||
|
className={cn('h-4 w-4', className)}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ArrowPathIcon = ({ className }: IconProps) => (
|
||||||
|
<svg
|
||||||
|
className={cn('h-4 w-4', 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>
|
||||||
|
);
|
||||||
410
src/components/layout/AppShell/AppHeader.tsx
Normal file
410
src/components/layout/AppShell/AppHeader.tsx
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
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 { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
||||||
|
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';
|
||||||
|
|
||||||
|
import type { TelegramPlatform } from '@/hooks/useTelegramSDK';
|
||||||
|
|
||||||
|
interface AppHeaderProps {
|
||||||
|
mobileMenuOpen: boolean;
|
||||||
|
setMobileMenuOpen: (open: boolean) => void;
|
||||||
|
onCommandPaletteOpen: () => void;
|
||||||
|
headerHeight: number;
|
||||||
|
isFullscreen: boolean;
|
||||||
|
safeAreaInset: { top: number; bottom: number; left: number; right: number };
|
||||||
|
contentSafeAreaInset: { top: number; bottom: number; left: number; right: number };
|
||||||
|
telegramPlatform?: TelegramPlatform;
|
||||||
|
wheelEnabled?: boolean;
|
||||||
|
referralEnabled?: boolean;
|
||||||
|
hasContests?: boolean;
|
||||||
|
hasPolls?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppHeader({
|
||||||
|
mobileMenuOpen,
|
||||||
|
setMobileMenuOpen,
|
||||||
|
onCommandPaletteOpen,
|
||||||
|
headerHeight,
|
||||||
|
isFullscreen,
|
||||||
|
safeAreaInset,
|
||||||
|
contentSafeAreaInset,
|
||||||
|
telegramPlatform,
|
||||||
|
wheelEnabled,
|
||||||
|
referralEnabled,
|
||||||
|
hasContests,
|
||||||
|
hasPolls,
|
||||||
|
}: AppHeaderProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const location = useLocation();
|
||||||
|
const { user, logout, isAdmin } = 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;
|
||||||
|
|
||||||
|
// 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) + (telegramPlatform === 'android' ? 48 : 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)}>
|
||||||
|
<TicketNotificationBell isAdmin={isAdminActive()} />
|
||||||
|
</div>
|
||||||
|
<div 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/60" 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/95 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>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
512
src/components/layout/AppShell/AppShell.tsx
Normal file
512
src/components/layout/AppShell/AppShell.tsx
Normal file
@@ -0,0 +1,512 @@
|
|||||||
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
|
import { useLocation, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useAuthStore } from '@/store/auth';
|
||||||
|
import { useBackButton, useHaptic } from '@/platform';
|
||||||
|
import { useTelegramSDK } from '@/hooks/useTelegramSDK';
|
||||||
|
import { referralApi } from '@/api/referral';
|
||||||
|
import { wheelApi } from '@/api/wheel';
|
||||||
|
import { contestsApi } from '@/api/contests';
|
||||||
|
import { pollsApi } from '@/api/polls';
|
||||||
|
import {
|
||||||
|
brandingApi,
|
||||||
|
getCachedBranding,
|
||||||
|
setCachedBranding,
|
||||||
|
preloadLogo,
|
||||||
|
isLogoPreloaded,
|
||||||
|
} from '@/api/branding';
|
||||||
|
import { setCachedFullscreenEnabled } from '@/hooks/useTelegramSDK';
|
||||||
|
import { 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 { Aurora } from './Aurora';
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
platform,
|
||||||
|
isMobile,
|
||||||
|
} = useTelegramSDK();
|
||||||
|
const haptic = useHaptic();
|
||||||
|
|
||||||
|
// Only apply fullscreen UI adjustments on mobile Telegram (iOS/Android)
|
||||||
|
const isMobileFullscreen = isFullscreen && isMobile;
|
||||||
|
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
const [isKeyboardOpen, setIsKeyboardOpen] = useState(false);
|
||||||
|
|
||||||
|
// Scroll position restoration for admin pages
|
||||||
|
const scrollPositions = useRef<Record<string, number>>({});
|
||||||
|
|
||||||
|
// Disable browser's automatic scroll restoration
|
||||||
|
useEffect(() => {
|
||||||
|
if ('scrollRestoration' in history) {
|
||||||
|
history.scrollRestoration = 'manual';
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Continuously save scroll position for current path
|
||||||
|
useEffect(() => {
|
||||||
|
const currentPath = location.pathname;
|
||||||
|
|
||||||
|
// Only track scroll for admin pages
|
||||||
|
if (!currentPath.startsWith('/admin')) return;
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
scrollPositions.current[currentPath] = window.scrollY;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save on scroll
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
|
||||||
|
// Restore scroll position immediately (synchronous)
|
||||||
|
const savedPosition = scrollPositions.current[currentPath];
|
||||||
|
if (savedPosition !== undefined && savedPosition > 0) {
|
||||||
|
// Immediate restore
|
||||||
|
window.scrollTo({ top: savedPosition, behavior: 'instant' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', handleScroll);
|
||||||
|
};
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
// Branding
|
||||||
|
const { data: branding } = useQuery({
|
||||||
|
queryKey: ['branding'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const data = await brandingApi.getBranding();
|
||||||
|
setCachedBranding(data);
|
||||||
|
preloadLogo(data);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
initialData: getCachedBranding() ?? undefined,
|
||||||
|
staleTime: 60000,
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
});
|
||||||
|
|
||||||
|
const appName = branding ? branding.name : FALLBACK_NAME;
|
||||||
|
const logoLetter = branding?.logo_letter || FALLBACK_LOGO;
|
||||||
|
const hasCustomLogo = branding?.has_custom_logo || false;
|
||||||
|
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null;
|
||||||
|
|
||||||
|
// Set document title
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = appName || 'VPN';
|
||||||
|
}, [appName]);
|
||||||
|
|
||||||
|
// Update favicon
|
||||||
|
useEffect(() => {
|
||||||
|
if (!logoUrl) return;
|
||||||
|
|
||||||
|
const link =
|
||||||
|
document.querySelector<HTMLLinkElement>("link[rel*='icon']") ||
|
||||||
|
document.createElement('link');
|
||||||
|
link.type = 'image/x-icon';
|
||||||
|
link.rel = 'shortcut icon';
|
||||||
|
link.href = logoUrl;
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}, [logoUrl]);
|
||||||
|
|
||||||
|
// Fullscreen setting from server
|
||||||
|
const { data: fullscreenSetting } = useQuery({
|
||||||
|
queryKey: ['fullscreen-enabled'],
|
||||||
|
queryFn: brandingApi.getFullscreenEnabled,
|
||||||
|
staleTime: 60000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply fullscreen setting when loaded from server
|
||||||
|
// Only apply on mobile Telegram (iOS/Android) - desktop doesn't need fullscreen
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fullscreenSetting || !isTelegramWebApp) return;
|
||||||
|
|
||||||
|
// Update cache for future app starts
|
||||||
|
setCachedFullscreenEnabled(fullscreenSetting.enabled);
|
||||||
|
|
||||||
|
// Request fullscreen if enabled, not already fullscreen, and on mobile Telegram
|
||||||
|
if (fullscreenSetting.enabled && !isFullscreen && isMobile) {
|
||||||
|
requestFullscreen();
|
||||||
|
}
|
||||||
|
}, [fullscreenSetting, isTelegramWebApp, isFullscreen, requestFullscreen, isMobile]);
|
||||||
|
|
||||||
|
// Feature flags
|
||||||
|
const { data: referralTerms } = useQuery({
|
||||||
|
queryKey: ['referral-terms'],
|
||||||
|
queryFn: referralApi.getReferralTerms,
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
staleTime: 60000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: wheelConfig } = useQuery({
|
||||||
|
queryKey: ['wheel-config'],
|
||||||
|
queryFn: wheelApi.getConfig,
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
staleTime: 60000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: contestsCount } = useQuery({
|
||||||
|
queryKey: ['contests-count'],
|
||||||
|
queryFn: contestsApi.getCount,
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
staleTime: 60000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: pollsCount } = useQuery({
|
||||||
|
queryKey: ['polls-count'],
|
||||||
|
queryFn: pollsApi.getCount,
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
staleTime: 60000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// BackButton for Telegram Mini App
|
||||||
|
// Don't show back button on main tab pages (bottom nav) - users navigate via tabs
|
||||||
|
const mainTabPaths = ['/', '/subscription', '/balance', '/referral', '/support', '/wheel'];
|
||||||
|
const isMainTabPage = mainTabPaths.includes(location.pathname);
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
if (mobileMenuOpen) {
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate(-1);
|
||||||
|
}, [mobileMenuOpen, navigate]);
|
||||||
|
|
||||||
|
useBackButton(isMainTabPage ? null : handleBack);
|
||||||
|
|
||||||
|
// Keyboard detection for hiding bottom nav
|
||||||
|
useEffect(() => {
|
||||||
|
const handleFocusIn = (e: FocusEvent) => {
|
||||||
|
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)
|
||||||
|
// On iOS: contentSafeAreaInset.top includes status bar + dynamic island + Telegram header
|
||||||
|
// On Android: safeAreaInset.top only includes status bar, need to add Telegram header height (~48px)
|
||||||
|
const telegramHeaderHeight = platform === 'android' ? 48 : 45;
|
||||||
|
const headerHeight = isMobileFullscreen
|
||||||
|
? 64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + telegramHeaderHeight
|
||||||
|
: 64;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
{/* Animated background */}
|
||||||
|
<Aurora />
|
||||||
|
|
||||||
|
{/* 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}
|
||||||
|
telegramPlatform={platform}
|
||||||
|
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">{children}</main>
|
||||||
|
|
||||||
|
{/* Mobile Bottom Navigation */}
|
||||||
|
<MobileBottomNav
|
||||||
|
isKeyboardOpen={isKeyboardOpen}
|
||||||
|
referralEnabled={referralTerms?.is_enabled}
|
||||||
|
wheelEnabled={wheelConfig?.is_enabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
263
src/components/layout/AppShell/Aurora.tsx
Normal file
263
src/components/layout/AppShell/Aurora.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Renderer, Program, Mesh, Color, Triangle } from 'ogl';
|
||||||
|
import { brandingApi } from '@/api/branding';
|
||||||
|
import { ThemeSettings, DEFAULT_THEME_COLORS } from '@/types/theme';
|
||||||
|
|
||||||
|
const VERT = /* glsl */ `#version 300 es
|
||||||
|
in vec2 position;
|
||||||
|
void main() {
|
||||||
|
gl_Position = vec4(position, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FRAG = /* glsl */ `#version 300 es
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
uniform float uTime;
|
||||||
|
uniform float uAmplitude;
|
||||||
|
uniform vec3 uColorStops[3];
|
||||||
|
uniform vec2 uResolution;
|
||||||
|
uniform float uBlend;
|
||||||
|
|
||||||
|
out vec4 fragColor;
|
||||||
|
|
||||||
|
vec3 permute(vec3 x) { return mod(((x * 34.0) + 1.0) * x, 289.0); }
|
||||||
|
|
||||||
|
float snoise(vec2 v) {
|
||||||
|
const vec4 C = vec4(0.211324865405187, 0.366025403784439,
|
||||||
|
-0.577350269189626, 0.024390243902439);
|
||||||
|
vec2 i = floor(v + dot(v, C.yy));
|
||||||
|
vec2 x0 = v - i + dot(i, C.xx);
|
||||||
|
vec2 i1;
|
||||||
|
i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
|
||||||
|
vec4 x12 = x0.xyxy + C.xxzz;
|
||||||
|
x12.xy -= i1;
|
||||||
|
i = mod(i, 289.0);
|
||||||
|
|
||||||
|
vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0))
|
||||||
|
+ i.x + vec3(0.0, i1.x, 1.0));
|
||||||
|
|
||||||
|
vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy),
|
||||||
|
dot(x12.zw, x12.zw)), 0.0);
|
||||||
|
m = m * m;
|
||||||
|
m = m * m;
|
||||||
|
|
||||||
|
vec3 x = 2.0 * fract(p * C.www) - 1.0;
|
||||||
|
vec3 h = abs(x) - 0.5;
|
||||||
|
vec3 ox = floor(x + 0.5);
|
||||||
|
vec3 a0 = x - ox;
|
||||||
|
|
||||||
|
m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
|
||||||
|
|
||||||
|
vec3 g;
|
||||||
|
g.x = a0.x * x0.x + h.x * x0.y;
|
||||||
|
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
|
||||||
|
return 130.0 * dot(m, g);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ColorStop {
|
||||||
|
vec3 color;
|
||||||
|
float position;
|
||||||
|
};
|
||||||
|
|
||||||
|
#define COLOR_RAMP(colors, factor, finalColor) { \
|
||||||
|
int index = 0; \
|
||||||
|
for (int i = 0; i < colors.length() - 1; i++) { \
|
||||||
|
ColorStop currentColor = cyclingColors[i]; \
|
||||||
|
bool isInBetween = cyclingColors[i].position <= factor; \
|
||||||
|
index = isInBetween ? i : index; \
|
||||||
|
} \
|
||||||
|
ColorStop currentColor = cyclingColors[index]; \
|
||||||
|
ColorStop nextColor = cyclingColors[index + 1]; \
|
||||||
|
float range = cyclingColors[index + 1].position - currentColor.position; \
|
||||||
|
float lerpFactor = (factor - currentColor.position) / range; \
|
||||||
|
finalColor = mix(currentColor.color, nextColor.color, lerpFactor); \
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec2 uv = gl_FragCoord.xy / uResolution;
|
||||||
|
|
||||||
|
ColorStop cyclingColors[3];
|
||||||
|
cyclingColors[0] = ColorStop(uColorStops[0], 0.0);
|
||||||
|
cyclingColors[1] = ColorStop(uColorStops[1], 0.5);
|
||||||
|
cyclingColors[2] = ColorStop(uColorStops[2], 1.0);
|
||||||
|
|
||||||
|
float noiseValue = snoise(uv * uAmplitude + uTime) * 0.5 + 0.5;
|
||||||
|
|
||||||
|
vec3 rampColor;
|
||||||
|
COLOR_RAMP(cyclingColors, noiseValue, rampColor);
|
||||||
|
|
||||||
|
fragColor = vec4(rampColor, uBlend);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function hexToRgb(hex: string): [number, number, number] {
|
||||||
|
hex = hex.replace('#', '');
|
||||||
|
if (hex.length === 3) {
|
||||||
|
hex = hex
|
||||||
|
.split('')
|
||||||
|
.map((c) => c + c)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
const r = parseInt(hex.substring(0, 2), 16) / 255;
|
||||||
|
const g = parseInt(hex.substring(2, 4), 16) / 255;
|
||||||
|
const b = parseInt(hex.substring(4, 6), 16) / 255;
|
||||||
|
return [r, g, b];
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateColorStops(accent: string, background: string): string[] {
|
||||||
|
const [ar, ag, ab] = hexToRgb(accent);
|
||||||
|
|
||||||
|
// Color 1: Dark background
|
||||||
|
const color1 = background;
|
||||||
|
|
||||||
|
// Color 2: Accent at 40% intensity
|
||||||
|
const midR = Math.round(ar * 255 * 0.4);
|
||||||
|
const midG = Math.round(ag * 255 * 0.4);
|
||||||
|
const midB = Math.round(ab * 255 * 0.4);
|
||||||
|
const color2 = `#${midR.toString(16).padStart(2, '0')}${midG.toString(16).padStart(2, '0')}${midB.toString(16).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
// Color 3: Accent at 70% intensity
|
||||||
|
const brightR = Math.round(ar * 255 * 0.7);
|
||||||
|
const brightG = Math.round(ag * 255 * 0.7);
|
||||||
|
const brightB = Math.round(ab * 255 * 0.7);
|
||||||
|
const color3 = `#${brightR.toString(16).padStart(2, '0')}${brightG.toString(16).padStart(2, '0')}${brightB.toString(16).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
return [color1, color2, color3];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Aurora() {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const animationFrameRef = useRef<number>(0);
|
||||||
|
const rendererRef = useRef<Renderer | null>(null);
|
||||||
|
const programRef = useRef<Program | null>(null);
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Fetch animation setting
|
||||||
|
const { data: animationSetting } = useQuery({
|
||||||
|
queryKey: ['animation-enabled'],
|
||||||
|
queryFn: brandingApi.getAnimationEnabled,
|
||||||
|
staleTime: 60000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isEnabled = animationSetting?.enabled ?? false;
|
||||||
|
|
||||||
|
// Get theme colors from cache (already fetched by ThemeColorsProvider)
|
||||||
|
const themeColors =
|
||||||
|
queryClient.getQueryData<ThemeSettings>(['theme-colors']) || DEFAULT_THEME_COLORS;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEnabled || !containerRef.current) return;
|
||||||
|
|
||||||
|
// Check for reduced motion preference
|
||||||
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
if (prefersReducedMotion) return;
|
||||||
|
|
||||||
|
const container = containerRef.current;
|
||||||
|
const renderer = new Renderer({
|
||||||
|
alpha: true,
|
||||||
|
antialias: true,
|
||||||
|
powerPreference: 'low-power',
|
||||||
|
});
|
||||||
|
rendererRef.current = renderer;
|
||||||
|
const gl = renderer.gl;
|
||||||
|
gl.clearColor(0, 0, 0, 0);
|
||||||
|
container.appendChild(gl.canvas);
|
||||||
|
|
||||||
|
const geometry = new Triangle(gl);
|
||||||
|
|
||||||
|
const colorStops = generateColorStops(themeColors.accent, themeColors.darkBackground);
|
||||||
|
const colorStopsArray = colorStops
|
||||||
|
.map((hex) => {
|
||||||
|
const c = new Color(hex);
|
||||||
|
return [c.r, c.g, c.b];
|
||||||
|
})
|
||||||
|
.flat();
|
||||||
|
|
||||||
|
const program = new Program(gl, {
|
||||||
|
vertex: VERT,
|
||||||
|
fragment: FRAG,
|
||||||
|
uniforms: {
|
||||||
|
uTime: { value: 0 },
|
||||||
|
uAmplitude: { value: 1.0 },
|
||||||
|
uColorStops: { value: colorStopsArray },
|
||||||
|
uResolution: { value: [container.offsetWidth, container.offsetHeight] },
|
||||||
|
uBlend: { value: 1.0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
programRef.current = program;
|
||||||
|
|
||||||
|
const mesh = new Mesh(gl, { geometry, program });
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
if (!containerRef.current || !rendererRef.current || !programRef.current) return;
|
||||||
|
const w = containerRef.current.offsetWidth;
|
||||||
|
const h = containerRef.current.offsetHeight;
|
||||||
|
rendererRef.current.setSize(w, h);
|
||||||
|
programRef.current.uniforms.uResolution.value = [w, h];
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
resize();
|
||||||
|
|
||||||
|
let lastTime = 0;
|
||||||
|
const targetFPS = 30;
|
||||||
|
const frameInterval = 1000 / targetFPS;
|
||||||
|
const speed = 0.3; // Slow and smooth
|
||||||
|
|
||||||
|
function animate(currentTime: number) {
|
||||||
|
animationFrameRef.current = requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
const delta = currentTime - lastTime;
|
||||||
|
if (delta < frameInterval) return;
|
||||||
|
|
||||||
|
lastTime = currentTime - (delta % frameInterval);
|
||||||
|
|
||||||
|
if (programRef.current && rendererRef.current) {
|
||||||
|
programRef.current.uniforms.uTime.value += speed * 0.01;
|
||||||
|
rendererRef.current.render({ scene: mesh });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
animationFrameRef.current = requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', resize);
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
if (rendererRef.current && container.contains(rendererRef.current.gl.canvas)) {
|
||||||
|
container.removeChild(rendererRef.current.gl.canvas);
|
||||||
|
}
|
||||||
|
rendererRef.current = null;
|
||||||
|
programRef.current = null;
|
||||||
|
};
|
||||||
|
}, [isEnabled, themeColors.accent, themeColors.darkBackground]);
|
||||||
|
|
||||||
|
if (!isEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blur overlay color from accent (very subtle)
|
||||||
|
const [r, g, b] = hexToRgb(themeColors.accent);
|
||||||
|
const blurColor = `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, 0.03)`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* WebGL Aurora canvas */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="pointer-events-none fixed inset-0 z-0"
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
/>
|
||||||
|
{/* Blur overlay */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-none fixed inset-0 z-0"
|
||||||
|
style={{
|
||||||
|
backdropFilter: 'blur(80px)',
|
||||||
|
WebkitBackdropFilter: 'blur(80px)',
|
||||||
|
backgroundColor: blurColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
src/components/layout/AppShell/MobileBottomNav.tsx
Normal file
86
src/components/layout/AppShell/MobileBottomNav.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
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, WheelIcon } from './icons';
|
||||||
|
|
||||||
|
interface MobileBottomNavProps {
|
||||||
|
isKeyboardOpen: boolean;
|
||||||
|
referralEnabled?: boolean;
|
||||||
|
wheelEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileBottomNav({
|
||||||
|
isKeyboardOpen,
|
||||||
|
referralEnabled,
|
||||||
|
wheelEnabled,
|
||||||
|
}: MobileBottomNavProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const location = useLocation();
|
||||||
|
const { haptic } = usePlatform();
|
||||||
|
|
||||||
|
const isActive = (path: string) => location.pathname === path;
|
||||||
|
|
||||||
|
// Core navigation items for bottom bar
|
||||||
|
// When wheel is enabled, it replaces Support in the bottom nav (Support is still accessible via hamburger menu)
|
||||||
|
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 }] : []),
|
||||||
|
...(wheelEnabled
|
||||||
|
? [{ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon }]
|
||||||
|
: [{ 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/components/layout/AppShell/MovingGradient.tsx
Normal file
57
src/components/layout/AppShell/MovingGradient.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animated moving gradient background.
|
||||||
|
* Uses CSS variables for colors to support theme switching.
|
||||||
|
* Lightweight - pure CSS gradients with Framer Motion animation.
|
||||||
|
*/
|
||||||
|
export function MovingGradient() {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 -z-10 overflow-hidden">
|
||||||
|
{/* Base background */}
|
||||||
|
<div className="absolute inset-0 bg-dark-950" />
|
||||||
|
|
||||||
|
{/* Animated gradient layer */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 opacity-60"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'radial-gradient(ellipse 80% 50% at 20% 40%, rgba(var(--color-accent-500), 0.12) 0%, transparent 50%), radial-gradient(ellipse 60% 40% at 80% 60%, rgba(var(--color-accent-500), 0.08) 0%, transparent 50%)',
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
backgroundPosition: ['0% 0%', '100% 100%', '0% 0%'],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 20,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: 'linear',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Second animated layer - moves in opposite direction */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 opacity-40"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'radial-gradient(ellipse 70% 60% at 70% 30%, rgba(var(--color-accent-500), 0.1) 0%, transparent 50%), radial-gradient(ellipse 50% 50% at 30% 70%, rgba(var(--color-accent-500), 0.06) 0%, transparent 50%)',
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
backgroundPosition: ['100% 100%', '0% 0%', '100% 100%'],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 25,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: 'linear',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Subtle noise texture overlay */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-[0.02]"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/components/layout/AppShell/icons.tsx
Normal file
24
src/components/layout/AppShell/icons.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// Re-export from centralized icons
|
||||||
|
export {
|
||||||
|
HomeIcon,
|
||||||
|
SubscriptionIcon,
|
||||||
|
WalletIcon,
|
||||||
|
UsersIcon,
|
||||||
|
ChatIcon,
|
||||||
|
UserIcon,
|
||||||
|
LogoutIcon,
|
||||||
|
SunIcon,
|
||||||
|
MoonIcon,
|
||||||
|
MenuIcon,
|
||||||
|
CloseIcon,
|
||||||
|
GamepadIcon,
|
||||||
|
ClipboardIcon,
|
||||||
|
InfoIcon,
|
||||||
|
CogIcon,
|
||||||
|
WheelIcon,
|
||||||
|
SearchIcon,
|
||||||
|
PlusIcon,
|
||||||
|
ArrowRightIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
PaletteIcon,
|
||||||
|
} from '../../icons';
|
||||||
5
src/components/layout/AppShell/index.ts
Normal file
5
src/components/layout/AppShell/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { AppShell } from './AppShell';
|
||||||
|
export { DesktopSidebar } from './DesktopSidebar';
|
||||||
|
export { MobileBottomNav } from './MobileBottomNav';
|
||||||
|
export { AppHeader } from './AppHeader';
|
||||||
|
export { Aurora } from './Aurora';
|
||||||
@@ -1,820 +1,17 @@
|
|||||||
import { Link, useLocation } from 'react-router-dom';
|
import { AppShell } from './AppShell';
|
||||||
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';
|
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Icons as simple SVG components
|
/**
|
||||||
const HomeIcon = () => (
|
* Main layout component that wraps all pages.
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
* Uses the new AppShell system with:
|
||||||
<path
|
* - Desktop sidebar navigation
|
||||||
strokeLinecap="round"
|
* - Mobile bottom navigation
|
||||||
strokeLinejoin="round"
|
* - Command palette (⌘K)
|
||||||
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"
|
* - Platform-aware features (Telegram integration)
|
||||||
/>
|
*/
|
||||||
</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>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function Layout({ children }: LayoutProps) {
|
export default function Layout({ children }: LayoutProps) {
|
||||||
const { t } = useTranslation();
|
return <AppShell>{children}</AppShell>;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
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';
|
||||||
102
src/components/primitives/Button/Button.tsx
Normal file
102
src/components/primitives/Button/Button.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>;
|
||||||
2
src/components/primitives/Button/index.ts
Normal file
2
src/components/primitives/Button/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { Button, type ButtonProps } from './Button';
|
||||||
|
export { buttonVariants, type ButtonVariants } from './Button.variants';
|
||||||
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';
|
||||||
283
src/components/primitives/Sheet/Sheet.tsx
Normal file
283
src/components/primitives/Sheet/Sheet.tsx
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
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 { 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';
|
export type BentoSize = 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ const glowClasses = `
|
|||||||
|
|
||||||
export const BentoCard = forwardRef<HTMLDivElement, BentoCardProps>((props, ref) => {
|
export const BentoCard = forwardRef<HTMLDivElement, BentoCardProps>((props, ref) => {
|
||||||
const { size = 'sm', children, className = '', hover = false, glow = false } = props;
|
const { size = 'sm', children, className = '', hover = false, glow = false } = props;
|
||||||
|
const haptic = useHaptic();
|
||||||
|
|
||||||
const classes = [
|
const classes = [
|
||||||
baseClasses,
|
baseClasses,
|
||||||
@@ -74,10 +76,27 @@ export const BentoCard = forwardRef<HTMLDivElement, BentoCardProps>((props, ref)
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ');
|
.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') {
|
if (props.as === 'link') {
|
||||||
const { to, state } = props as BentoCardLinkProps;
|
const { to, state } = props as BentoCardLinkProps;
|
||||||
return (
|
return (
|
||||||
<Link to={to} state={state} className={classes}>
|
<Link
|
||||||
|
to={to}
|
||||||
|
state={state}
|
||||||
|
className={classes}
|
||||||
|
onClick={hover ? () => haptic.impact('light') : undefined}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
@@ -89,7 +108,7 @@ export const BentoCard = forwardRef<HTMLDivElement, BentoCardProps>((props, ref)
|
|||||||
<button
|
<button
|
||||||
ref={ref as React.Ref<HTMLButtonElement>}
|
ref={ref as React.Ref<HTMLButtonElement>}
|
||||||
type={type}
|
type={type}
|
||||||
onClick={onClick}
|
onClick={withHaptic(onClick)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={classes}
|
className={classes}
|
||||||
>
|
>
|
||||||
@@ -100,7 +119,7 @@ export const BentoCard = forwardRef<HTMLDivElement, BentoCardProps>((props, ref)
|
|||||||
|
|
||||||
const { onClick } = props as BentoCardDivProps;
|
const { onClick } = props as BentoCardDivProps;
|
||||||
return (
|
return (
|
||||||
<div ref={ref} onClick={onClick} className={classes}>
|
<div ref={ref} onClick={withHaptic(onClick)} className={classes}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</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;
|
||||||
@@ -22,24 +22,32 @@ const FortuneWheel = memo(function FortuneWheel({
|
|||||||
onSpinComplete,
|
onSpinComplete,
|
||||||
}: FortuneWheelProps) {
|
}: FortuneWheelProps) {
|
||||||
const wheelRef = useRef<SVGGElement>(null);
|
const wheelRef = useRef<SVGGElement>(null);
|
||||||
const [currentRotation, setCurrentRotation] = useState(0);
|
const accumulatedRotation = useRef(0);
|
||||||
|
const [displayRotation, setDisplayRotation] = useState(0);
|
||||||
const [lightPhase, setLightPhase] = useState(0);
|
const [lightPhase, setLightPhase] = useState(0);
|
||||||
|
|
||||||
// Animated lights effect - use phase instead of random array (less re-renders)
|
// Animated lights effect - always running, speed depends on spinning state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSpinning) {
|
// Faster animation when spinning, slower when idle
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(
|
||||||
setLightPhase((p) => (p + 1) % 3); // Just toggle phase 0-1-2
|
() => {
|
||||||
}, 250); // Slower interval = better performance
|
setLightPhase((p) => (p + 1) % 20);
|
||||||
|
},
|
||||||
|
isSpinning ? 100 : 300,
|
||||||
|
); // 100ms when spinning, 300ms when idle
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
} else {
|
|
||||||
setLightPhase(0);
|
|
||||||
}
|
|
||||||
}, [isSpinning]);
|
}, [isSpinning]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSpinning && targetRotation !== null && wheelRef.current) {
|
if (isSpinning && targetRotation !== null && wheelRef.current) {
|
||||||
setCurrentRotation(targetRotation);
|
const currentPos = accumulatedRotation.current % 360;
|
||||||
|
let delta = targetRotation - currentPos;
|
||||||
|
// Normalize delta to positive
|
||||||
|
while (delta < 0) delta += 360;
|
||||||
|
const newRotation = accumulatedRotation.current + 1800 + delta;
|
||||||
|
accumulatedRotation.current = newRotation;
|
||||||
|
setDisplayRotation(newRotation);
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
onSpinComplete();
|
onSpinComplete();
|
||||||
@@ -51,9 +59,13 @@ const FortuneWheel = memo(function FortuneWheel({
|
|||||||
|
|
||||||
// Memoize light pattern calculation
|
// Memoize light pattern calculation
|
||||||
const lightPattern = useMemo(() => {
|
const lightPattern = useMemo(() => {
|
||||||
|
const numLights = isSpinning ? 3 : 4; // 3 lights when spinning, 4 when idle
|
||||||
|
|
||||||
return Array.from({ length: 20 }, (_, i) => {
|
return Array.from({ length: 20 }, (_, i) => {
|
||||||
if (!isSpinning) return i % 2 === 0;
|
// Calculate distance from current lightPhase
|
||||||
return (i + lightPhase) % 3 !== 0;
|
const distance = (i - lightPhase + 20) % 20;
|
||||||
|
// Light is on if within range [0, numLights)
|
||||||
|
return distance < numLights;
|
||||||
});
|
});
|
||||||
}, [isSpinning, lightPhase]);
|
}, [isSpinning, lightPhase]);
|
||||||
|
|
||||||
@@ -107,17 +119,6 @@ const FortuneWheel = memo(function FortuneWheel({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Position for text - between hub and emoji
|
|
||||||
const getTextPosition = (index: number) => {
|
|
||||||
const angle = (index * sectorAngle + sectorAngle / 2 - 90) * (Math.PI / 180);
|
|
||||||
const textRadius = prizeRadius * 0.45;
|
|
||||||
return {
|
|
||||||
x: center + textRadius * Math.cos(angle),
|
|
||||||
y: center + textRadius * Math.sin(angle),
|
|
||||||
rotation: index * sectorAngle + sectorAngle / 2,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Alternate colors for sectors
|
// Alternate colors for sectors
|
||||||
const getSectorColors = (index: number, baseColor?: string) => {
|
const getSectorColors = (index: number, baseColor?: string) => {
|
||||||
if (baseColor) return baseColor;
|
if (baseColor) return baseColor;
|
||||||
@@ -134,15 +135,6 @@ const FortuneWheel = memo(function FortuneWheel({
|
|||||||
return colors[index % colors.length];
|
return colors[index % colors.length];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Truncate text intelligently
|
|
||||||
const truncateText = (text: string, maxLen: number) => {
|
|
||||||
if (text.length <= maxLen) return text;
|
|
||||||
return text.substring(0, maxLen - 1) + '..';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate max text length based on number of sectors
|
|
||||||
const maxTextLength = prizes.length <= 4 ? 12 : prizes.length <= 6 ? 10 : 8;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative mx-auto w-full max-w-[380px] select-none">
|
<div className="relative mx-auto w-full max-w-[380px] select-none">
|
||||||
{/* Outer glow effect */}
|
{/* Outer glow effect */}
|
||||||
@@ -265,11 +257,12 @@ const FortuneWheel = memo(function FortuneWheel({
|
|||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* LED lights on outer ring */}
|
{/* LED lights on outer ring - positioned toward outer edge to avoid bleeding into sectors */}
|
||||||
{Array.from({ length: 20 }).map((_, i) => {
|
{Array.from({ length: 20 }).map((_, i) => {
|
||||||
const angle = (i * 18 - 90) * (Math.PI / 180);
|
const angle = (i * 18 - 90) * (Math.PI / 180);
|
||||||
const dotX = center + outerRadius * Math.cos(angle);
|
const ledRadius = outerRadius + 3;
|
||||||
const dotY = center + outerRadius * Math.sin(angle);
|
const dotX = center + ledRadius * Math.cos(angle);
|
||||||
|
const dotY = center + ledRadius * Math.sin(angle);
|
||||||
const isLit = lightPattern[i] ?? i % 2 === 0;
|
const isLit = lightPattern[i] ?? i % 2 === 0;
|
||||||
return (
|
return (
|
||||||
<g key={`led-${i}`}>
|
<g key={`led-${i}`}>
|
||||||
@@ -277,16 +270,16 @@ const FortuneWheel = memo(function FortuneWheel({
|
|||||||
<circle
|
<circle
|
||||||
cx={dotX}
|
cx={dotX}
|
||||||
cy={dotY}
|
cy={dotY}
|
||||||
r={6}
|
r={5}
|
||||||
fill="#FEF08A"
|
fill="#FEF08A"
|
||||||
opacity={0.5}
|
opacity={0.4}
|
||||||
style={{ filter: 'blur(3px)' }}
|
style={{ filter: 'blur(2px)' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<circle
|
<circle
|
||||||
cx={dotX}
|
cx={dotX}
|
||||||
cy={dotY}
|
cy={dotY}
|
||||||
r={4}
|
r={3.5}
|
||||||
fill={isLit ? '#FEF08A' : '#374151'}
|
fill={isLit ? '#FEF08A' : '#374151'}
|
||||||
stroke={isLit ? '#FDE047' : '#1F2937'}
|
stroke={isLit ? '#FDE047' : '#1F2937'}
|
||||||
strokeWidth="1"
|
strokeWidth="1"
|
||||||
@@ -300,7 +293,7 @@ const FortuneWheel = memo(function FortuneWheel({
|
|||||||
ref={wheelRef}
|
ref={wheelRef}
|
||||||
style={{
|
style={{
|
||||||
transformOrigin: `${center}px ${center}px`,
|
transformOrigin: `${center}px ${center}px`,
|
||||||
transform: `rotate(${currentRotation}deg)`,
|
transform: `rotate(${displayRotation}deg)`,
|
||||||
transition: isSpinning ? 'transform 5s cubic-bezier(0.15, 0.6, 0.1, 1)' : 'none',
|
transition: isSpinning ? 'transform 5s cubic-bezier(0.15, 0.6, 0.1, 1)' : 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -354,28 +347,7 @@ const FortuneWheel = memo(function FortuneWheel({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Prize content - Text */}
|
{/* Prize content - Text removed, only emoji visible on wheel */}
|
||||||
{prizes.map((prize, index) => {
|
|
||||||
const pos = getTextPosition(index);
|
|
||||||
const displayText = truncateText(prize.display_name, maxTextLength);
|
|
||||||
return (
|
|
||||||
<text
|
|
||||||
key={`text-${prize.id}`}
|
|
||||||
x={pos.x}
|
|
||||||
y={pos.y}
|
|
||||||
textAnchor="middle"
|
|
||||||
dominantBaseline="middle"
|
|
||||||
fontSize={prizes.length <= 4 ? '13' : prizes.length <= 6 ? '11' : '9'}
|
|
||||||
fontWeight="700"
|
|
||||||
fill="#FFFFFF"
|
|
||||||
transform={`rotate(${pos.rotation}, ${pos.x}, ${pos.y})`}
|
|
||||||
filter="url(#textShadow)"
|
|
||||||
style={{ letterSpacing: '0.03em' }}
|
|
||||||
>
|
|
||||||
{displayText}
|
|
||||||
</text>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
{/* Center hub */}
|
{/* Center hub */}
|
||||||
|
|||||||
53
src/components/wheel/WheelLegend.tsx
Normal file
53
src/components/wheel/WheelLegend.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import type { WheelPrize } from '../../api/wheel';
|
||||||
|
|
||||||
|
interface WheelLegendProps {
|
||||||
|
prizes: WheelPrize[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const WheelLegend = memo(function WheelLegend({ prizes }: WheelLegendProps) {
|
||||||
|
// Get sector color (same logic as in FortuneWheel)
|
||||||
|
const getSectorColor = (index: number, baseColor?: string) => {
|
||||||
|
if (baseColor) return baseColor;
|
||||||
|
const colors = [
|
||||||
|
'#8B5CF6',
|
||||||
|
'#EC4899',
|
||||||
|
'#3B82F6',
|
||||||
|
'#10B981',
|
||||||
|
'#F59E0B',
|
||||||
|
'#EF4444',
|
||||||
|
'#6366F1',
|
||||||
|
'#14B8A6',
|
||||||
|
];
|
||||||
|
return colors[index % colors.length];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{prizes.map((prize, index) => {
|
||||||
|
const color = getSectorColor(index, prize.color);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={prize.id}
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-dark-700/30 bg-dark-800/50 p-2.5 transition-colors hover:bg-dark-800"
|
||||||
|
>
|
||||||
|
{/* Color indicator */}
|
||||||
|
<div className="h-8 w-1 shrink-0 rounded-full" style={{ backgroundColor: color }} />
|
||||||
|
|
||||||
|
{/* Emoji */}
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center text-xl">
|
||||||
|
{prize.emoji}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prize name */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-sm font-medium text-dark-100">{prize.display_name}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default WheelLegend;
|
||||||
@@ -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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
30
src/hooks/useTelegramDnd.ts
Normal file
30
src/hooks/useTelegramDnd.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useTelegramSDK } from './useTelegramSDK';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for drag-and-drop operations in Telegram Mini App.
|
||||||
|
* Note: Vertical swipes are now globally disabled at app init,
|
||||||
|
* so this hook just provides no-op callbacks for compatibility.
|
||||||
|
*/
|
||||||
|
export function useTelegramDnd() {
|
||||||
|
const { isTelegramWebApp } = useTelegramSDK();
|
||||||
|
|
||||||
|
const onDragStart = useCallback(() => {
|
||||||
|
// No-op: swipes are globally disabled
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onDragEnd = useCallback(() => {
|
||||||
|
// No-op: swipes are globally disabled
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onDragCancel = useCallback(() => {
|
||||||
|
// No-op: swipes are globally disabled
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
onDragCancel,
|
||||||
|
isTelegramWebApp,
|
||||||
|
};
|
||||||
|
}
|
||||||
198
src/hooks/useTelegramSDK.ts
Normal file
198
src/hooks/useTelegramSDK.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached fullscreen setting
|
||||||
|
*/
|
||||||
|
export const getCachedFullscreenEnabled = (): boolean => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(FULLSCREEN_CACHE_KEY) === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set cached fullscreen setting
|
||||||
|
*/
|
||||||
|
export const setCachedFullscreenEnabled = (enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(FULLSCREEN_CACHE_KEY, String(enabled));
|
||||||
|
} catch {
|
||||||
|
// localStorage not available
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we're actually running inside Telegram Mini App
|
||||||
|
*/
|
||||||
|
export function isInTelegramWebApp(): boolean {
|
||||||
|
const webApp = window.Telegram?.WebApp;
|
||||||
|
return Boolean(webApp?.initData && webApp.initData.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if running on mobile Telegram client (iOS/Android)
|
||||||
|
*/
|
||||||
|
export function isTelegramMobile(): boolean {
|
||||||
|
const webApp = window.Telegram?.WebApp;
|
||||||
|
if (!webApp?.platform) return false;
|
||||||
|
return webApp.platform === 'ios' || webApp.platform === 'android';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Telegram init data for authentication
|
||||||
|
*/
|
||||||
|
export function getTelegramInitData(): string | null {
|
||||||
|
const webApp = window.Telegram?.WebApp;
|
||||||
|
return webApp?.initData || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize Telegram WebApp (basic setup without SDK)
|
||||||
|
*/
|
||||||
|
export function initTelegramSDK() {
|
||||||
|
if (!isInTelegramWebApp()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tg = window.Telegram?.WebApp;
|
||||||
|
if (!tg) return;
|
||||||
|
|
||||||
|
// Basic initialization
|
||||||
|
tg.ready();
|
||||||
|
tg.expand();
|
||||||
|
|
||||||
|
// Disable closing confirmation by default
|
||||||
|
tg.disableClosingConfirmation?.();
|
||||||
|
|
||||||
|
// Disable vertical swipes to prevent accidental closures
|
||||||
|
tg.disableVerticalSwipes?.();
|
||||||
|
|
||||||
|
// Auto-enter fullscreen if enabled in settings (mobile only)
|
||||||
|
const fullscreenEnabled = getCachedFullscreenEnabled();
|
||||||
|
if (fullscreenEnabled && isTelegramMobile() && tg.requestFullscreen) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!tg.isFullscreen) {
|
||||||
|
tg.requestFullscreen?.();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type for platform values
|
||||||
|
*/
|
||||||
|
export type TelegramPlatform =
|
||||||
|
| 'android'
|
||||||
|
| 'ios'
|
||||||
|
| 'tdesktop'
|
||||||
|
| 'macos'
|
||||||
|
| 'weba'
|
||||||
|
| 'webk'
|
||||||
|
| 'unigram'
|
||||||
|
| 'unknown'
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
// Default values
|
||||||
|
const defaultInsets = { top: 0, bottom: 0, left: 0, right: 0 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for Telegram WebApp integration
|
||||||
|
* Uses native window.Telegram.WebApp API
|
||||||
|
*/
|
||||||
|
export function useTelegramSDK() {
|
||||||
|
const inTelegram = isInTelegramWebApp();
|
||||||
|
const tg = window.Telegram?.WebApp;
|
||||||
|
|
||||||
|
const platform = useMemo<TelegramPlatform>(() => {
|
||||||
|
if (!inTelegram) return undefined;
|
||||||
|
return tg?.platform as TelegramPlatform;
|
||||||
|
}, [inTelegram, tg?.platform]);
|
||||||
|
|
||||||
|
const isMobile = platform === 'ios' || platform === 'android';
|
||||||
|
|
||||||
|
// Safe area insets from Telegram WebApp
|
||||||
|
const safeAreaInset = useMemo(() => {
|
||||||
|
if (!inTelegram || !tg?.safeAreaInset) return defaultInsets;
|
||||||
|
return {
|
||||||
|
top: tg.safeAreaInset.top || 0,
|
||||||
|
bottom: tg.safeAreaInset.bottom || 0,
|
||||||
|
left: tg.safeAreaInset.left || 0,
|
||||||
|
right: tg.safeAreaInset.right || 0,
|
||||||
|
};
|
||||||
|
}, [inTelegram, tg?.safeAreaInset]);
|
||||||
|
|
||||||
|
const contentSafeAreaInset = useMemo(() => {
|
||||||
|
if (!inTelegram || !tg?.contentSafeAreaInset) return defaultInsets;
|
||||||
|
return {
|
||||||
|
top: tg.contentSafeAreaInset.top || 0,
|
||||||
|
bottom: tg.contentSafeAreaInset.bottom || 0,
|
||||||
|
left: tg.contentSafeAreaInset.left || 0,
|
||||||
|
right: tg.contentSafeAreaInset.right || 0,
|
||||||
|
};
|
||||||
|
}, [inTelegram, tg?.contentSafeAreaInset]);
|
||||||
|
|
||||||
|
const isFullscreen = tg?.isFullscreen ?? false;
|
||||||
|
const viewportHeight = tg?.viewportHeight ?? 0;
|
||||||
|
const viewportStableHeight = tg?.viewportStableHeight ?? 0;
|
||||||
|
const isExpanded = tg?.isExpanded ?? true;
|
||||||
|
|
||||||
|
const requestFullscreen = useCallback(() => {
|
||||||
|
if (!inTelegram || !tg?.requestFullscreen) return;
|
||||||
|
tg.requestFullscreen();
|
||||||
|
}, [inTelegram, tg]);
|
||||||
|
|
||||||
|
const exitFullscreen = useCallback(() => {
|
||||||
|
if (!inTelegram || !tg?.exitFullscreen) return;
|
||||||
|
tg.exitFullscreen();
|
||||||
|
}, [inTelegram, tg]);
|
||||||
|
|
||||||
|
const toggleFullscreen = useCallback(() => {
|
||||||
|
if (isFullscreen) {
|
||||||
|
exitFullscreen();
|
||||||
|
} else {
|
||||||
|
requestFullscreen();
|
||||||
|
}
|
||||||
|
}, [isFullscreen, requestFullscreen, exitFullscreen]);
|
||||||
|
|
||||||
|
const expand = useCallback(() => {
|
||||||
|
if (!inTelegram || !tg?.expand) return;
|
||||||
|
tg.expand();
|
||||||
|
}, [inTelegram, tg]);
|
||||||
|
|
||||||
|
const disableVerticalSwipes = useCallback(() => {
|
||||||
|
if (!inTelegram || !tg?.disableVerticalSwipes) return;
|
||||||
|
tg.disableVerticalSwipes();
|
||||||
|
}, [inTelegram, tg]);
|
||||||
|
|
||||||
|
const enableVerticalSwipes = useCallback(() => {
|
||||||
|
if (!inTelegram || !tg?.enableVerticalSwipes) return;
|
||||||
|
tg.enableVerticalSwipes();
|
||||||
|
}, [inTelegram, tg]);
|
||||||
|
|
||||||
|
const isFullscreenSupported = inTelegram && typeof tg?.requestFullscreen === 'function';
|
||||||
|
|
||||||
|
return {
|
||||||
|
isTelegramWebApp: inTelegram,
|
||||||
|
isFullscreen,
|
||||||
|
isFullscreenSupported,
|
||||||
|
safeAreaInset,
|
||||||
|
contentSafeAreaInset,
|
||||||
|
viewportHeight,
|
||||||
|
viewportStableHeight,
|
||||||
|
viewportWidth: 0, // Not available in native API
|
||||||
|
isExpanded,
|
||||||
|
platform,
|
||||||
|
isMobile,
|
||||||
|
requestFullscreen,
|
||||||
|
exitFullscreen,
|
||||||
|
toggleFullscreen,
|
||||||
|
expand,
|
||||||
|
disableVerticalSwipes,
|
||||||
|
enableVerticalSwipes,
|
||||||
|
viewport: null,
|
||||||
|
miniApp: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,180 +1,38 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
/**
|
||||||
|
* @deprecated This hook is deprecated. Use useTelegramSDK instead.
|
||||||
|
* This file is kept for backward compatibility and re-exports from useTelegramSDK.
|
||||||
|
*/
|
||||||
|
|
||||||
const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled';
|
import { useTelegramSDK } from './useTelegramSDK';
|
||||||
|
|
||||||
// Get cached fullscreen setting
|
// Re-export everything from useTelegramSDK for backward compatibility
|
||||||
export const getCachedFullscreenEnabled = (): boolean => {
|
export {
|
||||||
try {
|
getCachedFullscreenEnabled,
|
||||||
return localStorage.getItem(FULLSCREEN_CACHE_KEY) === 'true';
|
setCachedFullscreenEnabled,
|
||||||
} catch {
|
isInTelegramWebApp,
|
||||||
return false;
|
isTelegramMobile,
|
||||||
}
|
initTelegramSDK as initTelegramWebApp, // Alias for backward compatibility
|
||||||
};
|
} from './useTelegramSDK';
|
||||||
|
|
||||||
// Set cached fullscreen setting
|
|
||||||
export const setCachedFullscreenEnabled = (enabled: boolean) => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(FULLSCREEN_CACHE_KEY, String(enabled));
|
|
||||||
} catch {
|
|
||||||
// localStorage not available
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for Telegram WebApp API integration
|
* @deprecated Use useTelegramSDK instead
|
||||||
* Provides fullscreen mode, safe area insets, and other WebApp features
|
* Hook for Telegram WebApp API integration - backward compatible wrapper
|
||||||
*/
|
*/
|
||||||
export function useTelegramWebApp() {
|
export function useTelegramWebApp() {
|
||||||
// Initialize synchronously to avoid flash/flicker on first render
|
const sdk = useTelegramSDK();
|
||||||
const webApp = typeof window !== 'undefined' ? window.Telegram?.WebApp : undefined;
|
|
||||||
|
|
||||||
const [isFullscreen, setIsFullscreen] = useState(() => webApp?.isFullscreen || false);
|
|
||||||
const [isTelegramWebApp, setIsTelegramWebApp] = useState(() => !!webApp);
|
|
||||||
const [safeAreaInset, setSafeAreaInset] = useState(
|
|
||||||
() => webApp?.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
|
|
||||||
);
|
|
||||||
const [contentSafeAreaInset, setContentSafeAreaInset] = useState(
|
|
||||||
() => webApp?.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!webApp) {
|
|
||||||
setIsTelegramWebApp(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsTelegramWebApp(true);
|
|
||||||
setIsFullscreen(webApp.isFullscreen || false);
|
|
||||||
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 });
|
|
||||||
setContentSafeAreaInset(
|
|
||||||
webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Expand WebApp to full height
|
|
||||||
webApp.expand();
|
|
||||||
webApp.ready();
|
|
||||||
|
|
||||||
// Listen for fullscreen changes
|
|
||||||
const handleFullscreenChanged = () => {
|
|
||||||
setIsFullscreen(webApp.isFullscreen || false);
|
|
||||||
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 });
|
|
||||||
setContentSafeAreaInset(
|
|
||||||
webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Listen for safe area changes
|
|
||||||
const handleSafeAreaChanged = () => {
|
|
||||||
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 });
|
|
||||||
setContentSafeAreaInset(
|
|
||||||
webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
webApp.onEvent('fullscreenChanged', handleFullscreenChanged);
|
|
||||||
webApp.onEvent('safeAreaChanged', handleSafeAreaChanged);
|
|
||||||
webApp.onEvent('contentSafeAreaChanged', handleSafeAreaChanged);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
webApp.offEvent('fullscreenChanged', handleFullscreenChanged);
|
|
||||||
webApp.offEvent('safeAreaChanged', handleSafeAreaChanged);
|
|
||||||
webApp.offEvent('contentSafeAreaChanged', handleSafeAreaChanged);
|
|
||||||
};
|
|
||||||
}, [webApp]);
|
|
||||||
|
|
||||||
const requestFullscreen = useCallback(() => {
|
|
||||||
if (webApp?.requestFullscreen) {
|
|
||||||
try {
|
|
||||||
webApp.requestFullscreen();
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Fullscreen not supported:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [webApp]);
|
|
||||||
|
|
||||||
const exitFullscreen = useCallback(() => {
|
|
||||||
if (webApp?.exitFullscreen) {
|
|
||||||
try {
|
|
||||||
webApp.exitFullscreen();
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Exit fullscreen failed:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [webApp]);
|
|
||||||
|
|
||||||
const toggleFullscreen = useCallback(() => {
|
|
||||||
if (isFullscreen) {
|
|
||||||
exitFullscreen();
|
|
||||||
} else {
|
|
||||||
requestFullscreen();
|
|
||||||
}
|
|
||||||
}, [isFullscreen, requestFullscreen, exitFullscreen]);
|
|
||||||
|
|
||||||
const disableVerticalSwipes = useCallback(() => {
|
|
||||||
try {
|
|
||||||
if (webApp?.disableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
|
|
||||||
webApp.disableVerticalSwipes();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Not supported in this version
|
|
||||||
}
|
|
||||||
}, [webApp]);
|
|
||||||
|
|
||||||
const enableVerticalSwipes = useCallback(() => {
|
|
||||||
try {
|
|
||||||
if (webApp?.enableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
|
|
||||||
webApp.enableVerticalSwipes();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Not supported in this version
|
|
||||||
}
|
|
||||||
}, [webApp]);
|
|
||||||
|
|
||||||
// Check if fullscreen is supported (Bot API 8.0+)
|
|
||||||
const isFullscreenSupported = Boolean(webApp?.requestFullscreen);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isTelegramWebApp,
|
isTelegramWebApp: sdk.isTelegramWebApp,
|
||||||
isFullscreen,
|
isFullscreen: sdk.isFullscreen,
|
||||||
isFullscreenSupported,
|
isFullscreenSupported: sdk.isFullscreenSupported,
|
||||||
safeAreaInset,
|
safeAreaInset: sdk.safeAreaInset,
|
||||||
contentSafeAreaInset,
|
contentSafeAreaInset: sdk.contentSafeAreaInset,
|
||||||
requestFullscreen,
|
requestFullscreen: sdk.requestFullscreen,
|
||||||
exitFullscreen,
|
exitFullscreen: sdk.exitFullscreen,
|
||||||
toggleFullscreen,
|
toggleFullscreen: sdk.toggleFullscreen,
|
||||||
disableVerticalSwipes,
|
disableVerticalSwipes: sdk.disableVerticalSwipes,
|
||||||
enableVerticalSwipes,
|
enableVerticalSwipes: sdk.enableVerticalSwipes,
|
||||||
webApp,
|
// For backward compatibility, expose webApp as the raw Telegram object
|
||||||
|
webApp: window.Telegram?.WebApp ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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();
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -1,48 +1,9 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useAuthStore } from '../store/auth';
|
import { useWebSocketContext } from '../providers/useWebSocketContext';
|
||||||
|
import { WSMessage } from '../providers/WebSocketProvider';
|
||||||
|
|
||||||
const isDev = import.meta.env.DEV;
|
// Re-export WSMessage type for convenience
|
||||||
|
export type { WSMessage };
|
||||||
export interface WSMessage {
|
|
||||||
type: string;
|
|
||||||
// Ticket events
|
|
||||||
ticket_id?: number;
|
|
||||||
title?: string;
|
|
||||||
// Common
|
|
||||||
message?: string;
|
|
||||||
user_id?: number;
|
|
||||||
is_admin?: boolean;
|
|
||||||
// Balance events
|
|
||||||
amount_kopeks?: number;
|
|
||||||
amount_rubles?: number;
|
|
||||||
new_balance_kopeks?: number;
|
|
||||||
new_balance_rubles?: number;
|
|
||||||
description?: string;
|
|
||||||
// Subscription events
|
|
||||||
expires_at?: string;
|
|
||||||
new_expires_at?: string;
|
|
||||||
tariff_name?: string;
|
|
||||||
days_left?: number;
|
|
||||||
// Device purchase events
|
|
||||||
devices_added?: number;
|
|
||||||
new_device_limit?: number;
|
|
||||||
// Traffic purchase events
|
|
||||||
traffic_gb_added?: number;
|
|
||||||
new_traffic_limit_gb?: number;
|
|
||||||
// Autopay events
|
|
||||||
required_kopeks?: number;
|
|
||||||
required_rubles?: number;
|
|
||||||
balance_kopeks?: number;
|
|
||||||
balance_rubles?: number;
|
|
||||||
reason?: string;
|
|
||||||
// Account events (ban/warning)
|
|
||||||
// Referral events
|
|
||||||
bonus_kopeks?: number;
|
|
||||||
bonus_rubles?: number;
|
|
||||||
referral_name?: string;
|
|
||||||
// Payment events
|
|
||||||
payment_method?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseWebSocketOptions {
|
interface UseWebSocketOptions {
|
||||||
onMessage?: (message: WSMessage) => void;
|
onMessage?: (message: WSMessage) => void;
|
||||||
@@ -51,141 +12,39 @@ interface UseWebSocketOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useWebSocket(options: UseWebSocketOptions = {}) {
|
export function useWebSocket(options: UseWebSocketOptions = {}) {
|
||||||
const { accessToken, isAuthenticated } = useAuthStore();
|
const { isConnected, subscribe } = useWebSocketContext();
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
|
||||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
|
||||||
const reconnectAttemptsRef = useRef(0);
|
|
||||||
const maxReconnectAttempts = 5;
|
|
||||||
const optionsRef = useRef(options);
|
const optionsRef = useRef(options);
|
||||||
|
const wasConnectedRef = useRef(false);
|
||||||
|
|
||||||
// Update options ref when they change
|
// Update options ref when they change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
optionsRef.current = options;
|
optionsRef.current = options;
|
||||||
}, [options]);
|
}, [options]);
|
||||||
|
|
||||||
const cleanup = useCallback(() => {
|
// Handle connection state changes
|
||||||
if (reconnectTimeoutRef.current) {
|
|
||||||
clearTimeout(reconnectTimeoutRef.current);
|
|
||||||
reconnectTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
if (pingIntervalRef.current) {
|
|
||||||
clearInterval(pingIntervalRef.current);
|
|
||||||
pingIntervalRef.current = null;
|
|
||||||
}
|
|
||||||
if (wsRef.current) {
|
|
||||||
wsRef.current.close();
|
|
||||||
wsRef.current = null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const connect = useCallback(() => {
|
|
||||||
if (!accessToken || !isAuthenticated) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't reconnect if already connected
|
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup();
|
|
||||||
|
|
||||||
// Build WebSocket URL
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
let host = window.location.host;
|
|
||||||
|
|
||||||
// Handle VITE_API_URL - can be absolute URL or relative path
|
|
||||||
const apiUrl = import.meta.env.VITE_API_URL;
|
|
||||||
if (apiUrl && (apiUrl.startsWith('http://') || apiUrl.startsWith('https://'))) {
|
|
||||||
try {
|
|
||||||
host = new URL(apiUrl).host;
|
|
||||||
} catch {
|
|
||||||
// If URL parsing fails, use window.location.host
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If apiUrl is relative (like /api), use window.location.host
|
|
||||||
|
|
||||||
const wsUrl = `${protocol}//${host}/cabinet/ws?token=${accessToken}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const ws = new WebSocket(wsUrl);
|
|
||||||
wsRef.current = ws;
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
if (isDev) console.log('[WS] Connected');
|
|
||||||
setIsConnected(true);
|
|
||||||
reconnectAttemptsRef.current = 0;
|
|
||||||
optionsRef.current.onConnect?.();
|
|
||||||
|
|
||||||
// Setup ping interval (every 25 seconds)
|
|
||||||
pingIntervalRef.current = setInterval(() => {
|
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(JSON.stringify({ type: 'ping' }));
|
|
||||||
}
|
|
||||||
}, 25000);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const message = JSON.parse(event.data) as WSMessage;
|
|
||||||
|
|
||||||
// Ignore pong messages
|
|
||||||
if (message.type === 'pong' || message.type === 'connected') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
optionsRef.current.onMessage?.(message);
|
|
||||||
} catch (e) {
|
|
||||||
if (isDev) console.error('[WS] Failed to parse message:', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
|
||||||
if (isDev) console.log('[WS] Disconnected:', event.code, event.reason);
|
|
||||||
setIsConnected(false);
|
|
||||||
optionsRef.current.onDisconnect?.();
|
|
||||||
|
|
||||||
if (pingIntervalRef.current) {
|
|
||||||
clearInterval(pingIntervalRef.current);
|
|
||||||
pingIntervalRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempt to reconnect if not closed intentionally
|
|
||||||
if (event.code !== 1000 && reconnectAttemptsRef.current < maxReconnectAttempts) {
|
|
||||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
|
|
||||||
if (isDev)
|
|
||||||
console.log(
|
|
||||||
`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
reconnectTimeoutRef.current = setTimeout(() => {
|
|
||||||
reconnectAttemptsRef.current++;
|
|
||||||
connect();
|
|
||||||
}, delay);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = (error) => {
|
|
||||||
if (isDev) console.error('[WS] Error:', error);
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
if (isDev) console.error('[WS] Failed to connect:', e);
|
|
||||||
}
|
|
||||||
}, [accessToken, isAuthenticated, cleanup]);
|
|
||||||
|
|
||||||
// Connect when authenticated
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated && accessToken) {
|
if (isConnected && !wasConnectedRef.current) {
|
||||||
connect();
|
wasConnectedRef.current = true;
|
||||||
} else {
|
optionsRef.current.onConnect?.();
|
||||||
cleanup();
|
} else if (!isConnected && wasConnectedRef.current) {
|
||||||
setIsConnected(false);
|
wasConnectedRef.current = false;
|
||||||
|
optionsRef.current.onDisconnect?.();
|
||||||
|
}
|
||||||
|
}, [isConnected]);
|
||||||
|
|
||||||
|
// Subscribe to messages
|
||||||
|
useEffect(() => {
|
||||||
|
if (!optionsRef.current.onMessage) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return cleanup;
|
const handler = (message: WSMessage) => {
|
||||||
}, [isAuthenticated, accessToken, connect, cleanup]);
|
optionsRef.current.onMessage?.(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsubscribe = subscribe(handler);
|
||||||
|
return unsubscribe;
|
||||||
|
}, [subscribe]);
|
||||||
|
|
||||||
return { isConnected };
|
return { isConnected };
|
||||||
}
|
}
|
||||||
|
|||||||
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));
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"and": "and",
|
"and": "and",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
|
"collapse": "Collapse",
|
||||||
"currency": "$",
|
"currency": "$",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
@@ -28,7 +29,8 @@
|
|||||||
"perGb": "/GB"
|
"perGb": "/GB"
|
||||||
},
|
},
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"saving": "Saving..."
|
"saving": "Saving...",
|
||||||
|
"understand": "Got it"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
@@ -411,7 +413,8 @@
|
|||||||
"additionalOptions": {
|
"additionalOptions": {
|
||||||
"title": "Additional options",
|
"title": "Additional options",
|
||||||
"buyDevices": "Buy more devices",
|
"buyDevices": "Buy more devices",
|
||||||
"currentDeviceLimit": "Current limit: {{count}} devices",
|
"currentDeviceLimit_one": "Current limit: {{count}} device",
|
||||||
|
"currentDeviceLimit_other": "Current limit: {{count}} devices",
|
||||||
"maxDevices": "max {{count}}",
|
"maxDevices": "max {{count}}",
|
||||||
"devicesUnavailable": "Device purchase is not available",
|
"devicesUnavailable": "Device purchase is not available",
|
||||||
"devicesUnit": "devices",
|
"devicesUnit": "devices",
|
||||||
@@ -419,13 +422,18 @@
|
|||||||
"reduceDevices": "Reduce devices",
|
"reduceDevices": "Reduce devices",
|
||||||
"reduceDevicesTitle": "Reduce device limit",
|
"reduceDevicesTitle": "Reduce device limit",
|
||||||
"reduceDevicesDescription": "You can reduce the number of devices to the tariff minimum",
|
"reduceDevicesDescription": "You can reduce the number of devices to the tariff minimum",
|
||||||
"minDeviceLimit": "Tariff minimum: {{count}} devices",
|
"minDeviceLimit_one": "Tariff minimum: {{count}} device",
|
||||||
"connectedDevices": "Connected devices: {{count}}",
|
"minDeviceLimit_other": "Tariff minimum: {{count}} devices",
|
||||||
"disconnectDevicesFirst": "Disconnect devices first to reduce the limit below {{count}}",
|
"connectedDevices_one": "Connected devices: {{count}}",
|
||||||
|
"connectedDevices_other": "Connected devices: {{count}}",
|
||||||
|
"disconnectDevicesFirst_one": "Disconnect devices first to reduce the limit below {{count}} device",
|
||||||
|
"disconnectDevicesFirst_other": "Disconnect devices first to reduce the limit below {{count}} devices",
|
||||||
"reduceUnavailable": "Device reduction is not available",
|
"reduceUnavailable": "Device reduction is not available",
|
||||||
|
"alreadyAtMinDeviceLimit": "Already at minimum device limit for your tariff",
|
||||||
"reduce": "Reduce",
|
"reduce": "Reduce",
|
||||||
"reducing": "Reducing...",
|
"reducing": "Reducing...",
|
||||||
"newDeviceLimit": "New limit: {{count}} devices",
|
"newDeviceLimit_one": "New limit: {{count}} device",
|
||||||
|
"newDeviceLimit_other": "New limit: {{count}} devices",
|
||||||
"buyTraffic": "Buy more traffic",
|
"buyTraffic": "Buy more traffic",
|
||||||
"currentTrafficLimit": "Current limit: {{limit}} GB (used {{used}} GB)",
|
"currentTrafficLimit": "Current limit: {{limit}} GB (used {{used}} GB)",
|
||||||
"buyTrafficTitle": "Buy more traffic",
|
"buyTrafficTitle": "Buy more traffic",
|
||||||
@@ -670,6 +678,7 @@
|
|||||||
"wheel": {
|
"wheel": {
|
||||||
"title": "Fortune Wheel",
|
"title": "Fortune Wheel",
|
||||||
"disabled": "Fortune Wheel is currently unavailable",
|
"disabled": "Fortune Wheel is currently unavailable",
|
||||||
|
"prizes": "Prizes",
|
||||||
"spinsRemaining": "Spins remaining today",
|
"spinsRemaining": "Spins remaining today",
|
||||||
"choosePayment": "Choose payment method",
|
"choosePayment": "Choose payment method",
|
||||||
"stars": "Stars",
|
"stars": "Stars",
|
||||||
@@ -739,7 +748,8 @@
|
|||||||
"paymentMethods": "Payment Methods",
|
"paymentMethods": "Payment Methods",
|
||||||
"campaigns": "Campaigns",
|
"campaigns": "Campaigns",
|
||||||
"promoOffers": "Promo Offers",
|
"promoOffers": "Promo Offers",
|
||||||
"promocodes": "Promo Codes"
|
"promocodes": "Promo Codes",
|
||||||
|
"promoGroups": "Discount Groups"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "Admin Panel",
|
"title": "Admin Panel",
|
||||||
@@ -760,7 +770,8 @@
|
|||||||
"paymentMethodsDesc": "Configure payment methods and order",
|
"paymentMethodsDesc": "Configure payment methods and order",
|
||||||
"campaignsDesc": "Advertising campaigns",
|
"campaignsDesc": "Advertising campaigns",
|
||||||
"promoOffersDesc": "Personal discounts",
|
"promoOffersDesc": "Personal discounts",
|
||||||
"promocodesDesc": "Manage promo codes"
|
"promocodesDesc": "Manage promo codes",
|
||||||
|
"promoGroupsDesc": "Discount groups for users"
|
||||||
},
|
},
|
||||||
"emailTemplates": {
|
"emailTemplates": {
|
||||||
"title": "Email Templates",
|
"title": "Email Templates",
|
||||||
@@ -780,7 +791,9 @@
|
|||||||
"resetted": "Template reset to default",
|
"resetted": "Template reset to default",
|
||||||
"testSent": "Test email sent",
|
"testSent": "Test email sent",
|
||||||
"variables": "Available Variables",
|
"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": {
|
"payments": {
|
||||||
"title": "Payment verification",
|
"title": "Payment verification",
|
||||||
@@ -861,6 +874,11 @@
|
|||||||
"memory": "Memory",
|
"memory": "Memory",
|
||||||
"uptime": "Uptime",
|
"uptime": "Uptime",
|
||||||
"traffic": "Traffic Statistics",
|
"traffic": "Traffic Statistics",
|
||||||
|
"traffic2days": "2 days",
|
||||||
|
"traffic7days": "7 days",
|
||||||
|
"traffic30days": "30 days",
|
||||||
|
"trafficMonth": "Month",
|
||||||
|
"trafficYear": "Year",
|
||||||
"usersByStatus": "Users by Status"
|
"usersByStatus": "Users by Status"
|
||||||
},
|
},
|
||||||
"nodes": {
|
"nodes": {
|
||||||
@@ -872,29 +890,68 @@
|
|||||||
"enable": "Enable",
|
"enable": "Enable",
|
||||||
"disable": "Disable",
|
"disable": "Disable",
|
||||||
"restartAll": "Restart All",
|
"restartAll": "Restart All",
|
||||||
"confirmRestartAll": "Are you sure you want to restart all nodes?"
|
"confirmRestartAll": "Are you sure you want to restart all nodes?",
|
||||||
|
"usersOnline": "online",
|
||||||
|
"usersOnlineCount_one": "{{count}} online",
|
||||||
|
"usersOnlineCount_other": "{{count}} online",
|
||||||
|
"trafficUsed": "used",
|
||||||
|
"uptimeLabel": "Uptime",
|
||||||
|
"stats": {
|
||||||
|
"total": "Total",
|
||||||
|
"online": "Online",
|
||||||
|
"offline": "Offline",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"users": "Users"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"squads": {
|
"squads": {
|
||||||
"synced": "Synced",
|
"synced": "Synced",
|
||||||
"notSynced": "Not synced",
|
"notSynced": "Not synced",
|
||||||
"noSquads": "No squads found",
|
"noSquads": "No squads found",
|
||||||
"syncServers": "Sync Servers"
|
"syncServers": "Sync Servers",
|
||||||
|
"members": "members",
|
||||||
|
"membersCount_one": "{{count}} member",
|
||||||
|
"membersCount_other": "{{count}} members",
|
||||||
|
"inbounds": "inbounds",
|
||||||
|
"inboundsCount_one": "{{count}} inbound",
|
||||||
|
"inboundsCount_other": "{{count}} inbounds",
|
||||||
|
"available": "Available",
|
||||||
|
"unavailable": "Unavailable",
|
||||||
|
"stats": {
|
||||||
|
"total": "Total",
|
||||||
|
"synced": "Synced",
|
||||||
|
"available": "Available",
|
||||||
|
"members": "Members"
|
||||||
|
},
|
||||||
|
"detail": "Squad Details",
|
||||||
|
"loadError": "Failed to load squad",
|
||||||
|
"info": "Information",
|
||||||
|
"originalName": "Original Name",
|
||||||
|
"countryCode": "Country",
|
||||||
|
"statsTitle": "Statistics",
|
||||||
|
"inboundsList": "Inbounds",
|
||||||
|
"localSettings": "Local Settings",
|
||||||
|
"trialEligible": "Trial Eligible",
|
||||||
|
"price": "Price",
|
||||||
|
"users": "Users"
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"autoSync": "Auto Sync",
|
"autoSync": "Auto Sync",
|
||||||
"runNow": "Run Now",
|
"runNow": "Run",
|
||||||
"manual": "Manual Sync",
|
"run": "Run",
|
||||||
"fromPanel": "Sync from Panel",
|
"running": "Running...",
|
||||||
"fromPanelDesc": "Import users from RemnaWave panel to bot database",
|
"schedule": "Schedule",
|
||||||
"toPanel": "Sync to Panel",
|
"status": "Status",
|
||||||
"toPanelDesc": "Export users from bot database to RemnaWave panel",
|
"lastRun": "Last Run",
|
||||||
"subscriptions": "Subscriptions",
|
"nextRun": "Next Run",
|
||||||
"validate": "Validate",
|
"enabled": "Enabled",
|
||||||
"validateDesc": "Check and fix subscription inconsistencies",
|
"disabled": "Disabled",
|
||||||
"cleanup": "Cleanup",
|
"success": "Success",
|
||||||
"cleanupDesc": "Remove orphaned subscriptions without users",
|
"runAutoSyncNow": "Run Auto Sync Now",
|
||||||
"statuses": "Sync Statuses",
|
"fromPanel": "From Panel",
|
||||||
"statusesDesc": "Synchronize subscription statuses with panel"
|
"fromPanelDesc": "Import users from RemnaWave to bot",
|
||||||
|
"toPanel": "To Panel",
|
||||||
|
"toPanelDesc": "Export users from bot to RemnaWave"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"wheel": {
|
"wheel": {
|
||||||
@@ -906,21 +963,29 @@
|
|||||||
"prizes": "Prizes",
|
"prizes": "Prizes",
|
||||||
"statistics": "Statistics"
|
"statistics": "Statistics"
|
||||||
},
|
},
|
||||||
|
"preview": "Preview",
|
||||||
"settings": {
|
"settings": {
|
||||||
"enableWheel": "Enable Wheel",
|
"enableWheel": "Enable Wheel",
|
||||||
"allowSpins": "Allow users to spin the wheel",
|
"allowSpins": "Allow users to spin the wheel",
|
||||||
|
"spinCost": "Spin Cost",
|
||||||
"costInStars": "Cost in Stars",
|
"costInStars": "Cost in Stars",
|
||||||
"costInDays": "Cost in Days",
|
"costInDays": "Cost in Days",
|
||||||
|
"limitsAndRtp": "Limits & RTP",
|
||||||
"rtpPercent": "RTP (Return to Player) %",
|
"rtpPercent": "RTP (Return to Player) %",
|
||||||
"dailyLimit": "Daily Spin Limit (0 = unlimited)",
|
"dailyLimit": "Daily Spin Limit (0 = unlimited)",
|
||||||
"minSubDays": "Min subscription days for day payment",
|
"minSubDays": "Min subscription days for day payment",
|
||||||
|
"promocodes": "Promocodes",
|
||||||
"promoPrefix": "Promo code prefix"
|
"promoPrefix": "Promo code prefix"
|
||||||
},
|
},
|
||||||
"prizes": {
|
"prizes": {
|
||||||
"addPrize": "Add Prize",
|
"addPrize": "Add Prize",
|
||||||
"editPrize": "Edit Prize",
|
"editPrize": "Edit Prize",
|
||||||
"noPrizes": "No prizes configured. Add some prizes to enable the wheel.",
|
"noPrizes": "No prizes configured. Add some prizes to enable the wheel.",
|
||||||
"deletePrize": "Delete this prize?",
|
"deletePrize": "Delete Prize",
|
||||||
|
"confirmDelete": "Are you sure you want to delete this prize? This action cannot be undone.",
|
||||||
|
"dragToReorder": "Drag to reorder",
|
||||||
|
"unsavedOrder": "Unsaved order changes",
|
||||||
|
"unsavedOrderHint": "Save or discard your changes",
|
||||||
"types": {
|
"types": {
|
||||||
"subscription_days": "Subscription Days",
|
"subscription_days": "Subscription Days",
|
||||||
"balance_bonus": "Balance Bonus",
|
"balance_bonus": "Balance Bonus",
|
||||||
@@ -982,6 +1047,9 @@
|
|||||||
"filter": "Filter",
|
"filter": "Filter",
|
||||||
"message": "Message",
|
"message": "Message",
|
||||||
"unknownAdmin": "Unknown",
|
"unknownAdmin": "Unknown",
|
||||||
|
"detail": "Broadcast",
|
||||||
|
"createdBy": "Created by",
|
||||||
|
"notFound": "Broadcast not found",
|
||||||
"prev": "Previous",
|
"prev": "Previous",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"status": {
|
"status": {
|
||||||
@@ -1174,6 +1242,8 @@
|
|||||||
"replyPlaceholder": "Type your reply...",
|
"replyPlaceholder": "Type your reply...",
|
||||||
"sendReply": "Send Reply",
|
"sendReply": "Send Reply",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
|
"settingsSubtitle": "Configure ticket system and SLA",
|
||||||
|
"settingsLoadError": "Failed to load settings",
|
||||||
"supportMode": "Support mode",
|
"supportMode": "Support mode",
|
||||||
"supportModeDesc": "Choose how users can contact support",
|
"supportModeDesc": "Choose how users can contact support",
|
||||||
"modeBoth": "Tickets and contact",
|
"modeBoth": "Tickets and contact",
|
||||||
@@ -1229,6 +1299,8 @@
|
|||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"confirmDelete": "Delete tariff?",
|
"confirmDelete": "Delete tariff?",
|
||||||
"confirmDeleteText": "This action cannot be undone. The tariff will be permanently deleted.",
|
"confirmDeleteText": "This action cannot be undone. The tariff will be permanently deleted.",
|
||||||
|
"confirmDeleteWithSubscriptions": "This tariff has {{count}} active subscriptions. After deletion, users will need to select a new tariff when renewing.",
|
||||||
|
"deleteSuccess": "Tariff deleted successfully",
|
||||||
"days": "days",
|
"days": "days",
|
||||||
"days_one": "{{count}} day",
|
"days_one": "{{count}} day",
|
||||||
"days_other": "{{count}} days",
|
"days_other": "{{count}} days",
|
||||||
@@ -1244,6 +1316,7 @@
|
|||||||
"serverTrafficLimit": "Traffic limit",
|
"serverTrafficLimit": "Traffic limit",
|
||||||
"useDefault": "default",
|
"useDefault": "default",
|
||||||
"selectType": "Select tariff type",
|
"selectType": "Select tariff type",
|
||||||
|
"selectTypeDesc": "Choose the billing method for your new tariff",
|
||||||
"periodTariff": "Period-based tariff",
|
"periodTariff": "Period-based tariff",
|
||||||
"periodTariffDesc": "Payment for period (7, 30, 90 days, etc.). Flexible periods and prices.",
|
"periodTariffDesc": "Payment for period (7, 30, 90 days, etc.). Flexible periods and prices.",
|
||||||
"dailyTariff": "Daily tariff",
|
"dailyTariff": "Daily tariff",
|
||||||
@@ -1260,6 +1333,7 @@
|
|||||||
"nameLabel": "Tariff name",
|
"nameLabel": "Tariff name",
|
||||||
"nameExamplePeriod": "E.g.: Standard",
|
"nameExamplePeriod": "E.g.: Standard",
|
||||||
"nameExampleDaily": "E.g.: Daily",
|
"nameExampleDaily": "E.g.: Daily",
|
||||||
|
"nameHint": "2 to 50 characters",
|
||||||
"descriptionLabel": "Description",
|
"descriptionLabel": "Description",
|
||||||
"descriptionPlaceholder2": "Brief tariff description",
|
"descriptionPlaceholder2": "Brief tariff description",
|
||||||
"trafficLimitLabel": "Traffic limit",
|
"trafficLimitLabel": "Traffic limit",
|
||||||
@@ -1268,6 +1342,7 @@
|
|||||||
"deviceLimitLabel": "Devices in tariff",
|
"deviceLimitLabel": "Devices in tariff",
|
||||||
"tierLevelLabel": "Tariff tier level",
|
"tierLevelLabel": "Tariff tier level",
|
||||||
"tierLevelHint": "Affects upgrade availability between tariffs",
|
"tierLevelHint": "Affects upgrade availability between tariffs",
|
||||||
|
"fieldRequired": "Required field",
|
||||||
"periodsTabHint": "Add periods and prices for the tariff. Users will be able to choose from the added periods.",
|
"periodsTabHint": "Add periods and prices for the tariff. Users will be able to choose from the added periods.",
|
||||||
"addPeriodTitle": "Add period",
|
"addPeriodTitle": "Add period",
|
||||||
"daysLabel": "Days",
|
"daysLabel": "Days",
|
||||||
@@ -1275,8 +1350,12 @@
|
|||||||
"addButton": "Add",
|
"addButton": "Add",
|
||||||
"noPeriodsHint": "No added periods. Add at least one period.",
|
"noPeriodsHint": "No added periods. Add at least one period.",
|
||||||
"daysShort": "d.",
|
"daysShort": "d.",
|
||||||
|
"serversTitle": "Servers",
|
||||||
"serversTabHint": "Select servers available on this tariff.",
|
"serversTabHint": "Select servers available on this tariff.",
|
||||||
"noServersAvailable": "No servers available",
|
"noServersAvailable": "No servers available",
|
||||||
|
"promoGroupsTitle": "Promo Groups",
|
||||||
|
"promoGroupsHint": "Select promo groups that have access to this tariff. If none selected — available to everyone.",
|
||||||
|
"noPromoGroups": "No promo groups",
|
||||||
"extraDeviceTitle": "Additional devices",
|
"extraDeviceTitle": "Additional devices",
|
||||||
"devicePriceLabel": "Device price (30 days):",
|
"devicePriceLabel": "Device price (30 days):",
|
||||||
"devicePriceHint": "0 = device purchase unavailable",
|
"devicePriceHint": "0 = device purchase unavailable",
|
||||||
@@ -1286,6 +1365,8 @@
|
|||||||
"trafficMaxLimitLabel": "Max. limit:",
|
"trafficMaxLimitLabel": "Max. limit:",
|
||||||
"trafficLimitHint2": "(0 = no limits)",
|
"trafficLimitHint2": "(0 = no limits)",
|
||||||
"trafficPackagesLabel": "Traffic packages:",
|
"trafficPackagesLabel": "Traffic packages:",
|
||||||
|
"addPackageTitle": "Add package",
|
||||||
|
"noPackagesHint": "No packages added. Add at least one package.",
|
||||||
"gbPackageUnit": "GB:",
|
"gbPackageUnit": "GB:",
|
||||||
"customDaysTitle": "Custom number of days",
|
"customDaysTitle": "Custom number of days",
|
||||||
"customDaysDesc": "User selects subscription duration",
|
"customDaysDesc": "User selects subscription duration",
|
||||||
@@ -1316,7 +1397,24 @@
|
|||||||
"periodType": "Period-based",
|
"periodType": "Period-based",
|
||||||
"dailyPriceLabel": "Price per day",
|
"dailyPriceLabel": "Price per day",
|
||||||
"currencyPerDay": "\u20bd/day",
|
"currencyPerDay": "\u20bd/day",
|
||||||
"dailyDeductionDesc": "Charged daily from user's balance"
|
"dailyDeductionDesc": "Charged daily from user's balance",
|
||||||
|
"dailyPriceRequired": "Enter a price per day greater than 0",
|
||||||
|
"cannotSave": "Cannot save tariff:",
|
||||||
|
"statusTitle": "Tariff Status",
|
||||||
|
"isActiveLabel": "Active",
|
||||||
|
"isActiveHint": "Inactive tariffs are not shown to users",
|
||||||
|
"isTrialLabel": "Trial tariff",
|
||||||
|
"isTrialHint": "Available for free trial period",
|
||||||
|
"validation": {
|
||||||
|
"nameRequired": "Enter tariff name",
|
||||||
|
"nameLength": "Name must be 2 to 50 characters",
|
||||||
|
"deviceLimitRequired": "Specify number of devices (minimum 1)",
|
||||||
|
"tierLevelInvalid": "Tier level must be between 1 and 10",
|
||||||
|
"fieldsRequired": "Fill in required fields (devices, tier level)",
|
||||||
|
"periodsRequired": "Add at least one period",
|
||||||
|
"dailyPriceRequired": "Enter a price per day greater than 0",
|
||||||
|
"trafficPackagesRequired": "Add at least one traffic package (or disable traffic topup)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"servers": {
|
"servers": {
|
||||||
"title": "Server Management",
|
"title": "Server Management",
|
||||||
@@ -1347,7 +1445,10 @@
|
|||||||
"enable": "Enable",
|
"enable": "Enable",
|
||||||
"disable": "Disable",
|
"disable": "Disable",
|
||||||
"toggleTrial": "Toggle Trial",
|
"toggleTrial": "Toggle Trial",
|
||||||
"loadError": "Failed to load server"
|
"loadError": "Failed to load server",
|
||||||
|
"mainSettings": "Main Settings",
|
||||||
|
"pricingAndLimits": "Pricing & Limits",
|
||||||
|
"updateError": "Failed to save changes"
|
||||||
},
|
},
|
||||||
"campaigns": {
|
"campaigns": {
|
||||||
"title": "Ad Campaigns",
|
"title": "Ad Campaigns",
|
||||||
@@ -1368,7 +1469,8 @@
|
|||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"inactive": "Inactive",
|
"inactive": "Inactive",
|
||||||
"registrations": "{{count}} registrations",
|
"registrations_one": "{{count}} registration",
|
||||||
|
"registrations_other": "{{count}} registrations",
|
||||||
"revenue": "{{amount}} revenue",
|
"revenue": "{{amount}} revenue",
|
||||||
"conversion": "{{rate}}% conversion",
|
"conversion": "{{rate}}% conversion",
|
||||||
"statistics": "Statistics",
|
"statistics": "Statistics",
|
||||||
@@ -1406,13 +1508,18 @@
|
|||||||
"saving": "Saving..."
|
"saving": "Saving..."
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
|
"title": "Campaign Statistics",
|
||||||
|
"loadError": "Failed to load statistics",
|
||||||
|
"detailedStats": "Detailed Statistics",
|
||||||
"registrations": "Registrations",
|
"registrations": "Registrations",
|
||||||
"revenue": "Revenue",
|
"revenue": "Revenue",
|
||||||
"paidUsers": "Paid",
|
"paidUsers": "Paid",
|
||||||
"conversion": "Conversion",
|
"conversion": "Conversion",
|
||||||
"bonusesIssued": "Bonuses issued",
|
"bonusesIssued": "Bonuses issued",
|
||||||
"subscriptionsIssued": "{{count}} subscriptions",
|
"subscriptionsIssued_one": "{{count}} subscription",
|
||||||
"tariffsIssued": "{{count}} tariffs",
|
"subscriptionsIssued_other": "{{count}} subscriptions",
|
||||||
|
"tariffsIssued_one": "{{count}} tariff",
|
||||||
|
"tariffsIssued_other": "{{count}} tariffs",
|
||||||
"avgRevenuePerUser": "Avg revenue per user",
|
"avgRevenuePerUser": "Avg revenue per user",
|
||||||
"avgFirstPayment": "Avg first payment",
|
"avgFirstPayment": "Avg first payment",
|
||||||
"trialSubscriptions": "Trial subscriptions",
|
"trialSubscriptions": "Trial subscriptions",
|
||||||
@@ -1423,7 +1530,10 @@
|
|||||||
"inactive": "Inactive",
|
"inactive": "Inactive",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"copied": "Copied!",
|
"copied": "Copied!",
|
||||||
"users": "Users"
|
"users": "Users",
|
||||||
|
"noUsers": "No registered users",
|
||||||
|
"paid": "Paid",
|
||||||
|
"hasSub": "Has sub"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"title": "Campaign Users",
|
"title": "Campaign Users",
|
||||||
@@ -1445,7 +1555,9 @@
|
|||||||
"deleteText": "This action cannot be undone. Campaigns with registrations cannot be deleted.",
|
"deleteText": "This action cannot be undone. Campaigns with registrations cannot be deleted.",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"delete": "Delete"
|
"delete": "Delete"
|
||||||
}
|
},
|
||||||
|
"loadError": "Failed to load campaign",
|
||||||
|
"updateError": "Failed to save changes"
|
||||||
},
|
},
|
||||||
"promocodes": {
|
"promocodes": {
|
||||||
"title": "Promo Codes",
|
"title": "Promo Codes",
|
||||||
@@ -1459,8 +1571,10 @@
|
|||||||
"until": "Until",
|
"until": "Until",
|
||||||
"discountForHours": "-{{percent}}% for {{hours}}h",
|
"discountForHours": "-{{percent}}% for {{hours}}h",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"promocodes": "Promo codes ({{count}})",
|
"promocodes_one": "Promo codes ({{count}} code)",
|
||||||
"groups": "Discount groups ({{count}})"
|
"promocodes_other": "Promo codes ({{count}} codes)",
|
||||||
|
"groups_one": "Discount groups ({{count}} group)",
|
||||||
|
"groups_other": "Discount groups ({{count}} groups)"
|
||||||
},
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"balance": "Balance",
|
"balance": "Balance",
|
||||||
@@ -1507,6 +1621,8 @@
|
|||||||
"save": "Save"
|
"save": "Save"
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
|
"title": "Promo code statistics",
|
||||||
|
"notFound": "Promo code not found",
|
||||||
"totalUses": "Total uses",
|
"totalUses": "Total uses",
|
||||||
"today": "Today",
|
"today": "Today",
|
||||||
"remaining": "Remaining",
|
"remaining": "Remaining",
|
||||||
@@ -1516,7 +1632,8 @@
|
|||||||
"daysLabel": "Days",
|
"daysLabel": "Days",
|
||||||
"discountLabel": "Discount",
|
"discountLabel": "Discount",
|
||||||
"validFor": "Valid for",
|
"validFor": "Valid for",
|
||||||
"hoursValue": "{{count}} hours",
|
"hoursValue_one": "{{count}} hour",
|
||||||
|
"hoursValue_other": "{{count}} hours",
|
||||||
"limit": "Limit",
|
"limit": "Limit",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
@@ -1557,7 +1674,8 @@
|
|||||||
"default": "Default",
|
"default": "Default",
|
||||||
"daysShort": "{{days}} d.",
|
"daysShort": "{{days}} d.",
|
||||||
"autoFrom": "Auto from {{amount}} RUB",
|
"autoFrom": "Auto from {{amount}} RUB",
|
||||||
"members": "{{count}} members"
|
"members_one": "{{count}} member",
|
||||||
|
"members_other": "{{count}} members"
|
||||||
},
|
},
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"deletePromocode": "Delete promo code?",
|
"deletePromocode": "Delete promo code?",
|
||||||
@@ -1565,6 +1683,62 @@
|
|||||||
"deletePromocodeText": "The promo code will be permanently deleted.",
|
"deletePromocodeText": "The promo code will be permanently deleted.",
|
||||||
"deleteGroupText": "The group will be deleted. Users will lose discounts from this group.",
|
"deleteGroupText": "The group will be deleted. Users will lose discounts from this group.",
|
||||||
"deleteButton": "Delete"
|
"deleteButton": "Delete"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"codeRequired": "Enter promo code",
|
||||||
|
"balanceRequired": "Bonus amount must be greater than 0",
|
||||||
|
"daysRequired": "Number of days must be greater than 0",
|
||||||
|
"groupRequired": "Select a discount group",
|
||||||
|
"discountPercentInvalid": "Discount percent must be between 1 and 100",
|
||||||
|
"discountHoursRequired": "Specify the discount validity period in hours"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"promoGroups": {
|
||||||
|
"title": "Discount Groups",
|
||||||
|
"subtitle": "Manage discount groups for users",
|
||||||
|
"addGroup": "Add Group",
|
||||||
|
"noGroups": "No discount groups",
|
||||||
|
"default": "Default",
|
||||||
|
"servers": "Servers",
|
||||||
|
"traffic": "Traffic",
|
||||||
|
"devices": "Devices",
|
||||||
|
"daysShort": "{{days}} days",
|
||||||
|
"autoFrom": "Auto from {{amount}} rub.",
|
||||||
|
"members": "{{count}} members",
|
||||||
|
"createTitle": "Create Discount Group",
|
||||||
|
"editTitle": "Edit Group",
|
||||||
|
"stats": {
|
||||||
|
"total": "Total groups",
|
||||||
|
"members": "Members",
|
||||||
|
"autoAssign": "With auto-assign"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete"
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"title": "Delete group?",
|
||||||
|
"text": "The group will be deleted. Users will lose discounts from this group.",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Delete"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"name": "Group name",
|
||||||
|
"namePlaceholder": "VIP customers",
|
||||||
|
"categoryDiscounts": "Category discounts",
|
||||||
|
"periodDiscounts": "Period discounts",
|
||||||
|
"add": "Add",
|
||||||
|
"periodHint": "Discount is applied when purchasing a subscription for the specified number of days",
|
||||||
|
"noPeriods": "No period discounts",
|
||||||
|
"daysPlaceholder": "Days",
|
||||||
|
"arrow": "days →",
|
||||||
|
"autoAssign": "Auto-assign when spent from",
|
||||||
|
"rub": "rub.",
|
||||||
|
"autoAssignHint": "0 = don't auto-assign",
|
||||||
|
"applyToAddons": "Apply to additional services",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"save": "Save"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
@@ -1573,7 +1747,8 @@
|
|||||||
"search": "Search by ID, name, username...",
|
"search": "Search by ID, name, username...",
|
||||||
"searchEmail": "Search by email...",
|
"searchEmail": "Search by email...",
|
||||||
"noData": "No users found",
|
"noData": "No users found",
|
||||||
"purchaseCount": "{{count}} purchases",
|
"purchaseCount_one": "{{count}} purchase",
|
||||||
|
"purchaseCount_other": "{{count}} purchases",
|
||||||
"noPurchases": "No purchases",
|
"noPurchases": "No purchases",
|
||||||
"stats": {
|
"stats": {
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
@@ -1713,7 +1888,8 @@
|
|||||||
"subtitle": "Templates, broadcasts and offer logs",
|
"subtitle": "Templates, broadcasts and offer logs",
|
||||||
"sendButton": "Send",
|
"sendButton": "Send",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"templates": "Templates ({{count}})",
|
"templates_one": "Templates ({{count}} template)",
|
||||||
|
"templates_other": "Templates ({{count}} templates)",
|
||||||
"logs": "Logs"
|
"logs": "Logs"
|
||||||
},
|
},
|
||||||
"noData": {
|
"noData": {
|
||||||
@@ -1755,17 +1931,27 @@
|
|||||||
"segment": "Segment",
|
"segment": "Segment",
|
||||||
"user": "User",
|
"user": "User",
|
||||||
"userIdPlaceholder": "Telegram ID or User ID",
|
"userIdPlaceholder": "Telegram ID or User ID",
|
||||||
|
"searchUserPlaceholder": "Search by name, username or Telegram ID...",
|
||||||
|
"noUsersFound": "No users found",
|
||||||
|
"hasSubscription": "Sub",
|
||||||
"preview": "Preview",
|
"preview": "Preview",
|
||||||
"sending": "Sending...",
|
"sending": "Sending...",
|
||||||
"sendButton": "Send"
|
"sendButton": "Send"
|
||||||
},
|
},
|
||||||
"result": {
|
"result": {
|
||||||
"sentTitle": "Sent!",
|
"sentTitle": "Sent!",
|
||||||
"offersCreated": "Offers created: {{count}}",
|
"offersCreated_one": "Offer created: {{count}}",
|
||||||
"notificationsSent": "Notifications sent: {{count}}",
|
"offersCreated_other": "Offers created: {{count}}",
|
||||||
"notificationsFailed": "(failed: {{count}})",
|
"notificationsSent_one": "Notification sent: {{count}}",
|
||||||
|
"notificationsSent_other": "Notifications sent: {{count}}",
|
||||||
|
"notificationsFailed_one": "(failed: {{count}})",
|
||||||
|
"notificationsFailed_other": "(failed: {{count}})",
|
||||||
"sendError": "Failed to send offer"
|
"sendError": "Failed to send offer"
|
||||||
},
|
},
|
||||||
|
"notFound": "Template not found",
|
||||||
|
"noActiveTemplates": "No active templates",
|
||||||
|
"backToList": "Back to offers",
|
||||||
|
"sendAnother": "Send another",
|
||||||
"actions": {
|
"actions": {
|
||||||
"created": "Created",
|
"created": "Created",
|
||||||
"claimed": "Claimed",
|
"claimed": "Claimed",
|
||||||
@@ -1825,7 +2011,8 @@
|
|||||||
"enable": "Enable",
|
"enable": "Enable",
|
||||||
"disable": "Disable",
|
"disable": "Disable",
|
||||||
"hide": "Hide ({{count}})",
|
"hide": "Hide ({{count}})",
|
||||||
"showMore": "Show {{count}} more nodes"
|
"showMore_one": "Show {{count}} more node",
|
||||||
|
"showMore_other": "Show {{count}} more nodes"
|
||||||
},
|
},
|
||||||
"topReferrers": {
|
"topReferrers": {
|
||||||
"title": "Top referrers",
|
"title": "Top referrers",
|
||||||
|
|||||||
@@ -28,7 +28,9 @@
|
|||||||
"add": "افزودن",
|
"add": "افزودن",
|
||||||
"refresh": "بازخوانی",
|
"refresh": "بازخوانی",
|
||||||
"retry": "تلاش مجدد",
|
"retry": "تلاش مجدد",
|
||||||
"saving": "در حال ذخیره..."
|
"saving": "در حال ذخیره...",
|
||||||
|
"understand": "متوجه شدم",
|
||||||
|
"collapse": "جمع کردن"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "داشبورد",
|
"dashboard": "داشبورد",
|
||||||
@@ -301,7 +303,7 @@
|
|||||||
"additionalOptions": {
|
"additionalOptions": {
|
||||||
"title": "گزینههای اضافی",
|
"title": "گزینههای اضافی",
|
||||||
"buyDevices": "خرید دستگاه بیشتر",
|
"buyDevices": "خرید دستگاه بیشتر",
|
||||||
"currentDeviceLimit": "محدودیت فعلی: {{count}} دستگاه",
|
"currentDeviceLimit_other": "محدودیت فعلی: {{count}} دستگاه",
|
||||||
"maxDevices": "حداکثر {{count}}",
|
"maxDevices": "حداکثر {{count}}",
|
||||||
"devicesUnavailable": "خرید دستگاه در دسترس نیست",
|
"devicesUnavailable": "خرید دستگاه در دسترس نیست",
|
||||||
"devicesUnit": "دستگاه",
|
"devicesUnit": "دستگاه",
|
||||||
@@ -565,6 +567,7 @@
|
|||||||
"wheel": {
|
"wheel": {
|
||||||
"title": "چرخ شانس",
|
"title": "چرخ شانس",
|
||||||
"disabled": "چرخ شانس فعلاً در دسترس نیست",
|
"disabled": "چرخ شانس فعلاً در دسترس نیست",
|
||||||
|
"prizes": "جوایز",
|
||||||
"spinsRemaining": "تعداد چرخش باقیمانده امروز",
|
"spinsRemaining": "تعداد چرخش باقیمانده امروز",
|
||||||
"choosePayment": "روش پرداخت را انتخاب کنید",
|
"choosePayment": "روش پرداخت را انتخاب کنید",
|
||||||
"stars": "Stars",
|
"stars": "Stars",
|
||||||
@@ -625,6 +628,7 @@
|
|||||||
"payments": "پرداختها",
|
"payments": "پرداختها",
|
||||||
"promoOffers": "پیشنهادات تبلیغاتی",
|
"promoOffers": "پیشنهادات تبلیغاتی",
|
||||||
"promocodes": "کدهای تخفیف",
|
"promocodes": "کدهای تخفیف",
|
||||||
|
"promoGroups": "گروههای تخفیف",
|
||||||
"remnawave": "RemnaWave",
|
"remnawave": "RemnaWave",
|
||||||
"users": "کاربران"
|
"users": "کاربران"
|
||||||
},
|
},
|
||||||
@@ -646,6 +650,7 @@
|
|||||||
"paymentsDesc": "تأیید پرداخت",
|
"paymentsDesc": "تأیید پرداخت",
|
||||||
"promoOffersDesc": "تخفیفهای شخصی",
|
"promoOffersDesc": "تخفیفهای شخصی",
|
||||||
"promocodesDesc": "مدیریت کدهای تخفیف",
|
"promocodesDesc": "مدیریت کدهای تخفیف",
|
||||||
|
"promoGroupsDesc": "گروههای تخفیف برای کاربران",
|
||||||
"remnawaveDesc": "مدیریت پنل و آمار",
|
"remnawaveDesc": "مدیریت پنل و آمار",
|
||||||
"usersDesc": "مدیریت کاربران ربات"
|
"usersDesc": "مدیریت کاربران ربات"
|
||||||
},
|
},
|
||||||
@@ -667,10 +672,13 @@
|
|||||||
"resetted": "قالب به پیشفرض بازگردانده شد",
|
"resetted": "قالب به پیشفرض بازگردانده شد",
|
||||||
"testSent": "ایمیل آزمایشی ارسال شد",
|
"testSent": "ایمیل آزمایشی ارسال شد",
|
||||||
"variables": "متغیرهای موجود",
|
"variables": "متغیرهای موجود",
|
||||||
"clickToCopy": "برای کپی کلیک کنید"
|
"clickToCopy": "برای کپی کلیک کنید",
|
||||||
|
"previewDesktopOnly": "پیشنمایش فقط در نسخه وب دسکتاپ در دسترس است",
|
||||||
|
"previewNotAvailable": "پیشنمایش در دسترس نیست"
|
||||||
},
|
},
|
||||||
"wheel": {
|
"wheel": {
|
||||||
"title": "تنظیمات چرخ شانس",
|
"title": "تنظیمات چرخ شانس",
|
||||||
|
"preview": "پیشنمایش",
|
||||||
"enabled": "فعال",
|
"enabled": "فعال",
|
||||||
"disabled": "غیرفعال",
|
"disabled": "غیرفعال",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
@@ -686,13 +694,20 @@
|
|||||||
"rtpPercent": "درصد RTP",
|
"rtpPercent": "درصد RTP",
|
||||||
"dailyLimit": "محدودیت روزانه (0 = نامحدود)",
|
"dailyLimit": "محدودیت روزانه (0 = نامحدود)",
|
||||||
"minSubDays": "حداقل روز اشتراک برای پرداخت روزانه",
|
"minSubDays": "حداقل روز اشتراک برای پرداخت روزانه",
|
||||||
"promoPrefix": "پیشوند کد تخفیف"
|
"promoPrefix": "پیشوند کد تخفیف",
|
||||||
|
"spinCost": "هزینه چرخش",
|
||||||
|
"limitsAndRtp": "محدودیتها و RTP",
|
||||||
|
"promocodes": "کدهای تخفیف"
|
||||||
},
|
},
|
||||||
"prizes": {
|
"prizes": {
|
||||||
"addPrize": "افزودن جایزه",
|
"addPrize": "افزودن جایزه",
|
||||||
"editPrize": "ویرایش جایزه",
|
"editPrize": "ویرایش جایزه",
|
||||||
"noPrizes": "جایزهای تنظیم نشده. برای فعالسازی چرخ جایزه اضافه کنید.",
|
"noPrizes": "جایزهای تنظیم نشده. برای فعالسازی چرخ جایزه اضافه کنید.",
|
||||||
"deletePrize": "این جایزه حذف شود؟",
|
"deletePrize": "این جایزه حذف شود؟",
|
||||||
|
"dragToReorder": "کشیدن برای مرتبسازی",
|
||||||
|
"unsavedOrder": "تغییرات ترتیب ذخیره نشده",
|
||||||
|
"unsavedOrderHint": "تغییرات را ذخیره یا لغو کنید",
|
||||||
|
"confirmDelete": "آیا از حذف این جایزه مطمئن هستید؟",
|
||||||
"types": {
|
"types": {
|
||||||
"subscription_days": "روز اشتراک",
|
"subscription_days": "روز اشتراک",
|
||||||
"balance_bonus": "جایزه موجودی",
|
"balance_bonus": "جایزه موجودی",
|
||||||
@@ -753,6 +768,9 @@
|
|||||||
"filter": "فیلتر",
|
"filter": "فیلتر",
|
||||||
"message": "پیام",
|
"message": "پیام",
|
||||||
"unknownAdmin": "نامعلوم",
|
"unknownAdmin": "نامعلوم",
|
||||||
|
"detail": "جزئیات پخش",
|
||||||
|
"createdBy": "ایجاد شده توسط",
|
||||||
|
"notFound": "پخش یافت نشد",
|
||||||
"prev": "قبلی",
|
"prev": "قبلی",
|
||||||
"next": "بعدی",
|
"next": "بعدی",
|
||||||
"status": {
|
"status": {
|
||||||
@@ -933,6 +951,8 @@
|
|||||||
"replyPlaceholder": "پاسخ خود را بنویسید...",
|
"replyPlaceholder": "پاسخ خود را بنویسید...",
|
||||||
"sendReply": "ارسال پاسخ",
|
"sendReply": "ارسال پاسخ",
|
||||||
"settings": "تنظیمات",
|
"settings": "تنظیمات",
|
||||||
|
"settingsSubtitle": "پیکربندی سیستم تیکت و SLA",
|
||||||
|
"settingsLoadError": "خطا در بارگذاری تنظیمات",
|
||||||
"supportMode": "حالت پشتیبانی",
|
"supportMode": "حالت پشتیبانی",
|
||||||
"supportModeDesc": "انتخاب کنید کاربران چگونه میتوانند با پشتیبانی تماس بگیرند",
|
"supportModeDesc": "انتخاب کنید کاربران چگونه میتوانند با پشتیبانی تماس بگیرند",
|
||||||
"modeBoth": "تیکت و تماس",
|
"modeBoth": "تیکت و تماس",
|
||||||
@@ -985,6 +1005,8 @@
|
|||||||
"delete": "حذف",
|
"delete": "حذف",
|
||||||
"confirmDelete": "تعرفه حذف شود؟",
|
"confirmDelete": "تعرفه حذف شود؟",
|
||||||
"confirmDeleteText": "این عمل قابل بازگشت نیست. تعرفه برای همیشه حذف میشود.",
|
"confirmDeleteText": "این عمل قابل بازگشت نیست. تعرفه برای همیشه حذف میشود.",
|
||||||
|
"confirmDeleteWithSubscriptions": "این تعرفه {{count}} اشتراک فعال دارد. پس از حذف، کاربران هنگام تمدید باید تعرفه جدید انتخاب کنند.",
|
||||||
|
"deleteSuccess": "تعرفه با موفقیت حذف شد",
|
||||||
"days": "روز",
|
"days": "روز",
|
||||||
"days_other": "{{count}} روز",
|
"days_other": "{{count}} روز",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
@@ -996,6 +1018,7 @@
|
|||||||
"serversHint": "سرورهای موجود برای این تعرفه را انتخاب کنید",
|
"serversHint": "سرورهای موجود برای این تعرفه را انتخاب کنید",
|
||||||
"noServers": "سرور موجود نیست",
|
"noServers": "سرور موجود نیست",
|
||||||
"selectType": "نوع تعرفه را انتخاب کنید",
|
"selectType": "نوع تعرفه را انتخاب کنید",
|
||||||
|
"selectTypeDesc": "روش صورتحساب برای تعرفه جدید را انتخاب کنید",
|
||||||
"periodTariff": "تعرفه دورهای",
|
"periodTariff": "تعرفه دورهای",
|
||||||
"periodTariffDesc": "پرداخت برای دوره (7، 30، 90 روز و غیره). دورهها و قیمتهای انعطافپذیر.",
|
"periodTariffDesc": "پرداخت برای دوره (7، 30، 90 روز و غیره). دورهها و قیمتهای انعطافپذیر.",
|
||||||
"dailyTariff": "تعرفه روزانه",
|
"dailyTariff": "تعرفه روزانه",
|
||||||
@@ -1012,6 +1035,7 @@
|
|||||||
"nameLabel": "نام تعرفه",
|
"nameLabel": "نام تعرفه",
|
||||||
"nameExamplePeriod": "مثال: استاندارد",
|
"nameExamplePeriod": "مثال: استاندارد",
|
||||||
"nameExampleDaily": "مثال: روزانه",
|
"nameExampleDaily": "مثال: روزانه",
|
||||||
|
"nameHint": "۲ تا ۵۰ کاراکتر",
|
||||||
"descriptionLabel": "توضیحات",
|
"descriptionLabel": "توضیحات",
|
||||||
"descriptionPlaceholder2": "توضیح مختصر تعرفه",
|
"descriptionPlaceholder2": "توضیح مختصر تعرفه",
|
||||||
"trafficLimitLabel": "محدودیت ترافیک",
|
"trafficLimitLabel": "محدودیت ترافیک",
|
||||||
@@ -1020,6 +1044,7 @@
|
|||||||
"deviceLimitLabel": "دستگاهها در تعرفه",
|
"deviceLimitLabel": "دستگاهها در تعرفه",
|
||||||
"tierLevelLabel": "سطح تعرفه",
|
"tierLevelLabel": "سطح تعرفه",
|
||||||
"tierLevelHint": "بر دسترسی ارتقا بین تعرفهها تأثیر میگذارد",
|
"tierLevelHint": "بر دسترسی ارتقا بین تعرفهها تأثیر میگذارد",
|
||||||
|
"fieldRequired": "فیلد اجباری",
|
||||||
"periodsTabHint": "دورهها و قیمتها را برای تعرفه اضافه کنید. کاربران میتوانند از دورههای اضافه شده انتخاب کنند.",
|
"periodsTabHint": "دورهها و قیمتها را برای تعرفه اضافه کنید. کاربران میتوانند از دورههای اضافه شده انتخاب کنند.",
|
||||||
"addPeriodTitle": "افزودن دوره",
|
"addPeriodTitle": "افزودن دوره",
|
||||||
"daysLabel": "روز",
|
"daysLabel": "روز",
|
||||||
@@ -1027,8 +1052,12 @@
|
|||||||
"addButton": "افزودن",
|
"addButton": "افزودن",
|
||||||
"noPeriodsHint": "هیچ دورهای اضافه نشده. حداقل یک دوره اضافه کنید.",
|
"noPeriodsHint": "هیچ دورهای اضافه نشده. حداقل یک دوره اضافه کنید.",
|
||||||
"daysShort": "روز",
|
"daysShort": "روز",
|
||||||
|
"serversTitle": "سرورها",
|
||||||
"serversTabHint": "سرورهای موجود در این تعرفه را انتخاب کنید.",
|
"serversTabHint": "سرورهای موجود در این تعرفه را انتخاب کنید.",
|
||||||
"noServersAvailable": "هیچ سروری در دسترس نیست",
|
"noServersAvailable": "هیچ سروری در دسترس نیست",
|
||||||
|
"promoGroupsTitle": "گروههای تبلیغاتی",
|
||||||
|
"promoGroupsHint": "گروههای تبلیغاتی که به این تعرفه دسترسی دارند را انتخاب کنید. اگر هیچکدام انتخاب نشود، همه دسترسی دارند.",
|
||||||
|
"noPromoGroups": "هیچ گروه تبلیغاتی وجود ندارد",
|
||||||
"extraDeviceTitle": "دستگاههای اضافی",
|
"extraDeviceTitle": "دستگاههای اضافی",
|
||||||
"devicePriceLabel": "قیمت دستگاه (30 روز):",
|
"devicePriceLabel": "قیمت دستگاه (30 روز):",
|
||||||
"devicePriceHint": "0 = خرید دستگاه در دسترس نیست",
|
"devicePriceHint": "0 = خرید دستگاه در دسترس نیست",
|
||||||
@@ -1038,6 +1067,8 @@
|
|||||||
"trafficMaxLimitLabel": "حداکثر محدودیت:",
|
"trafficMaxLimitLabel": "حداکثر محدودیت:",
|
||||||
"trafficLimitHint2": "(0 = بدون محدودیت)",
|
"trafficLimitHint2": "(0 = بدون محدودیت)",
|
||||||
"trafficPackagesLabel": "بستههای ترافیک:",
|
"trafficPackagesLabel": "بستههای ترافیک:",
|
||||||
|
"addPackageTitle": "افزودن بسته",
|
||||||
|
"noPackagesHint": "هیچ بستهای اضافه نشده است. حداقل یک بسته اضافه کنید.",
|
||||||
"gbPackageUnit": "GB:",
|
"gbPackageUnit": "GB:",
|
||||||
"customDaysTitle": "تعداد روزهای سفارشی",
|
"customDaysTitle": "تعداد روزهای سفارشی",
|
||||||
"customDaysDesc": "کاربر مدت اشتراک را انتخاب میکند",
|
"customDaysDesc": "کاربر مدت اشتراک را انتخاب میکند",
|
||||||
@@ -1068,7 +1099,24 @@
|
|||||||
"periodType": "دورهای",
|
"periodType": "دورهای",
|
||||||
"dailyPriceLabel": "قیمت روزانه",
|
"dailyPriceLabel": "قیمت روزانه",
|
||||||
"currencyPerDay": "₽/روز",
|
"currencyPerDay": "₽/روز",
|
||||||
"dailyDeductionDesc": "هر روز از موجودی کاربر کسر میشود"
|
"dailyDeductionDesc": "هر روز از موجودی کاربر کسر میشود",
|
||||||
|
"dailyPriceRequired": "قیمت روزانه بزرگتر از 0 را وارد کنید",
|
||||||
|
"cannotSave": "امکان ذخیره تعرفه وجود ندارد:",
|
||||||
|
"statusTitle": "وضعیت تعرفه",
|
||||||
|
"isActiveLabel": "فعال",
|
||||||
|
"isActiveHint": "تعرفههای غیرفعال به کاربران نمایش داده نمیشوند",
|
||||||
|
"isTrialLabel": "تعرفه آزمایشی",
|
||||||
|
"isTrialHint": "برای دوره آزمایشی رایگان در دسترس است",
|
||||||
|
"validation": {
|
||||||
|
"nameRequired": "نام تعرفه را وارد کنید",
|
||||||
|
"nameLength": "نام باید ۲ تا ۵۰ کاراکتر باشد",
|
||||||
|
"deviceLimitRequired": "تعداد دستگاهها را مشخص کنید (حداقل ۱)",
|
||||||
|
"tierLevelInvalid": "سطح تعرفه باید بین ۱ تا ۱۰ باشد",
|
||||||
|
"fieldsRequired": "فیلدهای اجباری را پر کنید (دستگاهها، سطح)",
|
||||||
|
"periodsRequired": "حداقل یک دوره اضافه کنید",
|
||||||
|
"dailyPriceRequired": "قیمت روزانه بزرگتر از 0 را وارد کنید",
|
||||||
|
"trafficPackagesRequired": "حداقل یک بسته ترافیک اضافه کنید (یا خرید ترافیک را غیرفعال کنید)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"servers": {
|
"servers": {
|
||||||
"title": "مدیریت سرورها",
|
"title": "مدیریت سرورها",
|
||||||
@@ -1099,7 +1147,10 @@
|
|||||||
"enable": "فعال",
|
"enable": "فعال",
|
||||||
"disable": "غیرفعال",
|
"disable": "غیرفعال",
|
||||||
"toggleTrial": "تغییر آزمایشی",
|
"toggleTrial": "تغییر آزمایشی",
|
||||||
"loadError": "بارگذاری سرور ناموفق بود"
|
"loadError": "بارگذاری سرور ناموفق بود",
|
||||||
|
"mainSettings": "تنظیمات اصلی",
|
||||||
|
"pricingAndLimits": "قیمتگذاری و محدودیتها",
|
||||||
|
"updateError": "ذخیره تغییرات ناموفق بود"
|
||||||
},
|
},
|
||||||
"paymentMethods": {
|
"paymentMethods": {
|
||||||
"title": "روشهای پرداخت",
|
"title": "روشهای پرداخت",
|
||||||
@@ -1158,7 +1209,7 @@
|
|||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"inactive": "غیرفعال",
|
"inactive": "غیرفعال",
|
||||||
"registrations": "{{count}} ثبتنام",
|
"registrations_other": "{{count}} ثبتنام",
|
||||||
"revenue": "{{amount}} درآمد",
|
"revenue": "{{amount}} درآمد",
|
||||||
"conversion": "{{rate}}% تبدیل",
|
"conversion": "{{rate}}% تبدیل",
|
||||||
"statistics": "آمار",
|
"statistics": "آمار",
|
||||||
@@ -1196,13 +1247,16 @@
|
|||||||
"saving": "در حال ذخیره..."
|
"saving": "در حال ذخیره..."
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
|
"title": "آمار کمپین",
|
||||||
|
"loadError": "خطا در بارگذاری آمار",
|
||||||
|
"detailedStats": "آمار دقیق",
|
||||||
"registrations": "ثبتنامها",
|
"registrations": "ثبتنامها",
|
||||||
"revenue": "درآمد",
|
"revenue": "درآمد",
|
||||||
"paidUsers": "پرداخت کرده",
|
"paidUsers": "پرداخت کرده",
|
||||||
"conversion": "نرخ تبدیل",
|
"conversion": "نرخ تبدیل",
|
||||||
"bonusesIssued": "پاداشهای صادر شده",
|
"bonusesIssued": "پاداشهای صادر شده",
|
||||||
"subscriptionsIssued": "{{count}} اشتراک",
|
"subscriptionsIssued_other": "{{count}} اشتراک",
|
||||||
"tariffsIssued": "{{count}} تعرفه",
|
"tariffsIssued_other": "{{count}} تعرفه",
|
||||||
"avgRevenuePerUser": "میانگین درآمد هر کاربر",
|
"avgRevenuePerUser": "میانگین درآمد هر کاربر",
|
||||||
"avgFirstPayment": "میانگین اولین پرداخت",
|
"avgFirstPayment": "میانگین اولین پرداخت",
|
||||||
"trialSubscriptions": "اشتراکهای آزمایشی",
|
"trialSubscriptions": "اشتراکهای آزمایشی",
|
||||||
@@ -1213,7 +1267,10 @@
|
|||||||
"inactive": "غیرفعال",
|
"inactive": "غیرفعال",
|
||||||
"copy": "کپی",
|
"copy": "کپی",
|
||||||
"copied": "کپی شد!",
|
"copied": "کپی شد!",
|
||||||
"users": "کاربران"
|
"users": "کاربران",
|
||||||
|
"noUsers": "کاربر ثبتنام شدهای وجود ندارد",
|
||||||
|
"paid": "پرداخت کرده",
|
||||||
|
"hasSub": "دارای اشتراک"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"title": "کاربران کمپین",
|
"title": "کاربران کمپین",
|
||||||
@@ -1235,7 +1292,9 @@
|
|||||||
"deleteText": "این عمل قابل بازگشت نیست. کمپینهایی که ثبتنام دارند قابل حذف نیستند.",
|
"deleteText": "این عمل قابل بازگشت نیست. کمپینهایی که ثبتنام دارند قابل حذف نیستند.",
|
||||||
"cancel": "لغو",
|
"cancel": "لغو",
|
||||||
"delete": "حذف"
|
"delete": "حذف"
|
||||||
}
|
},
|
||||||
|
"loadError": "بارگذاری کمپین ناموفق بود",
|
||||||
|
"updateError": "ذخیره تغییرات ناموفق بود"
|
||||||
},
|
},
|
||||||
"promocodes": {
|
"promocodes": {
|
||||||
"title": "کدهای تخفیف",
|
"title": "کدهای تخفیف",
|
||||||
@@ -1249,8 +1308,8 @@
|
|||||||
"until": "تا",
|
"until": "تا",
|
||||||
"discountForHours": "-{{percent}}% برای {{hours}} ساعت",
|
"discountForHours": "-{{percent}}% برای {{hours}} ساعت",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"promocodes": "کدهای تخفیف ({{count}})",
|
"promocodes_other": "کدهای تخفیف ({{count}})",
|
||||||
"groups": "گروههای تخفیف ({{count}})"
|
"groups_other": "گروههای تخفیف ({{count}})"
|
||||||
},
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"balance": "موجودی",
|
"balance": "موجودی",
|
||||||
@@ -1297,6 +1356,8 @@
|
|||||||
"save": "ذخیره"
|
"save": "ذخیره"
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
|
"title": "آمار کد تخفیف",
|
||||||
|
"notFound": "کد تخفیف یافت نشد",
|
||||||
"totalUses": "کل استفادهها",
|
"totalUses": "کل استفادهها",
|
||||||
"today": "امروز",
|
"today": "امروز",
|
||||||
"remaining": "باقیمانده",
|
"remaining": "باقیمانده",
|
||||||
@@ -1306,7 +1367,7 @@
|
|||||||
"daysLabel": "روزها",
|
"daysLabel": "روزها",
|
||||||
"discountLabel": "تخفیف",
|
"discountLabel": "تخفیف",
|
||||||
"validFor": "معتبر برای",
|
"validFor": "معتبر برای",
|
||||||
"hoursValue": "{{count}} ساعت",
|
"hoursValue_other": "{{count}} ساعت",
|
||||||
"limit": "محدودیت",
|
"limit": "محدودیت",
|
||||||
"status": "وضعیت",
|
"status": "وضعیت",
|
||||||
"active": "فعال",
|
"active": "فعال",
|
||||||
@@ -1347,7 +1408,7 @@
|
|||||||
"default": "پیشفرض",
|
"default": "پیشفرض",
|
||||||
"daysShort": "{{days}} روز",
|
"daysShort": "{{days}} روز",
|
||||||
"autoFrom": "خودکار از {{amount}} روبل",
|
"autoFrom": "خودکار از {{amount}} روبل",
|
||||||
"members": "{{count}} عضو"
|
"members_other": "{{count}} عضو"
|
||||||
},
|
},
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"deletePromocode": "کد تخفیف حذف شود؟",
|
"deletePromocode": "کد تخفیف حذف شود؟",
|
||||||
@@ -1355,6 +1416,62 @@
|
|||||||
"deletePromocodeText": "کد تخفیف به طور دائم حذف خواهد شد.",
|
"deletePromocodeText": "کد تخفیف به طور دائم حذف خواهد شد.",
|
||||||
"deleteGroupText": "گروه حذف خواهد شد. کاربران تخفیفهای این گروه را از دست خواهند داد.",
|
"deleteGroupText": "گروه حذف خواهد شد. کاربران تخفیفهای این گروه را از دست خواهند داد.",
|
||||||
"deleteButton": "حذف"
|
"deleteButton": "حذف"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"codeRequired": "کد تخفیف را وارد کنید",
|
||||||
|
"balanceRequired": "مبلغ پاداش باید بیشتر از 0 باشد",
|
||||||
|
"daysRequired": "تعداد روزها باید بیشتر از 0 باشد",
|
||||||
|
"groupRequired": "یک گروه تخفیف انتخاب کنید",
|
||||||
|
"discountPercentInvalid": "درصد تخفیف باید بین 1 تا 100 باشد",
|
||||||
|
"discountHoursRequired": "مدت اعتبار تخفیف را به ساعت مشخص کنید"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"promoGroups": {
|
||||||
|
"title": "گروههای تخفیف",
|
||||||
|
"subtitle": "مدیریت گروههای تخفیف برای کاربران",
|
||||||
|
"addGroup": "افزودن گروه",
|
||||||
|
"noGroups": "گروه تخفیفی وجود ندارد",
|
||||||
|
"default": "پیشفرض",
|
||||||
|
"servers": "سرورها",
|
||||||
|
"traffic": "ترافیک",
|
||||||
|
"devices": "دستگاهها",
|
||||||
|
"daysShort": "{{days}} روز",
|
||||||
|
"autoFrom": "خودکار از {{amount}} روبل",
|
||||||
|
"members": "{{count}} عضو",
|
||||||
|
"createTitle": "ایجاد گروه تخفیف",
|
||||||
|
"editTitle": "ویرایش گروه",
|
||||||
|
"stats": {
|
||||||
|
"total": "کل گروهها",
|
||||||
|
"members": "اعضا",
|
||||||
|
"autoAssign": "با تخصیص خودکار"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"edit": "ویرایش",
|
||||||
|
"delete": "حذف"
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"title": "گروه حذف شود؟",
|
||||||
|
"text": "گروه حذف خواهد شد. کاربران تخفیفهای این گروه را از دست خواهند داد.",
|
||||||
|
"cancel": "انصراف",
|
||||||
|
"delete": "حذف"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"name": "نام گروه",
|
||||||
|
"namePlaceholder": "مشتریان VIP",
|
||||||
|
"categoryDiscounts": "تخفیفهای دستهبندی",
|
||||||
|
"periodDiscounts": "تخفیفهای دورهای",
|
||||||
|
"add": "افزودن",
|
||||||
|
"periodHint": "تخفیف هنگام خرید اشتراک برای تعداد روز مشخص شده اعمال میشود",
|
||||||
|
"noPeriods": "تخفیف دورهای وجود ندارد",
|
||||||
|
"daysPlaceholder": "روز",
|
||||||
|
"arrow": "روز ←",
|
||||||
|
"autoAssign": "تخصیص خودکار هنگام هزینه از",
|
||||||
|
"rub": "روبل",
|
||||||
|
"autoAssignHint": "0 = تخصیص خودکار نشود",
|
||||||
|
"applyToAddons": "اعمال به خدمات اضافی",
|
||||||
|
"cancel": "انصراف",
|
||||||
|
"saving": "در حال ذخیره...",
|
||||||
|
"save": "ذخیره"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
@@ -1363,7 +1480,7 @@
|
|||||||
"search": "جستجو بر اساس شناسه، نام، نام کاربری...",
|
"search": "جستجو بر اساس شناسه، نام، نام کاربری...",
|
||||||
"searchEmail": "جستجو بر اساس ایمیل...",
|
"searchEmail": "جستجو بر اساس ایمیل...",
|
||||||
"noData": "کاربری یافت نشد",
|
"noData": "کاربری یافت نشد",
|
||||||
"purchaseCount": "{{count}} خرید",
|
"purchaseCount_other": "{{count}} خرید",
|
||||||
"noPurchases": "بدون خرید",
|
"noPurchases": "بدون خرید",
|
||||||
"stats": {
|
"stats": {
|
||||||
"total": "کل",
|
"total": "کل",
|
||||||
@@ -1474,7 +1591,7 @@
|
|||||||
"subtitle": "قالبها، ارسال گروهی و گزارش پیشنهادات",
|
"subtitle": "قالبها، ارسال گروهی و گزارش پیشنهادات",
|
||||||
"sendButton": "ارسال",
|
"sendButton": "ارسال",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"templates": "قالبها ({{count}})",
|
"templates_other": "قالبها ({{count}})",
|
||||||
"logs": "گزارشها"
|
"logs": "گزارشها"
|
||||||
},
|
},
|
||||||
"noData": {
|
"noData": {
|
||||||
@@ -1516,17 +1633,24 @@
|
|||||||
"segment": "بخش",
|
"segment": "بخش",
|
||||||
"user": "کاربر",
|
"user": "کاربر",
|
||||||
"userIdPlaceholder": "شناسه تلگرام یا شناسه کاربر",
|
"userIdPlaceholder": "شناسه تلگرام یا شناسه کاربر",
|
||||||
|
"searchUserPlaceholder": "جستجو بر اساس نام، نام کاربری یا شناسه تلگرام...",
|
||||||
|
"noUsersFound": "کاربری یافت نشد",
|
||||||
|
"hasSubscription": "اشتراک",
|
||||||
"preview": "پیشنمایش",
|
"preview": "پیشنمایش",
|
||||||
"sending": "در حال ارسال...",
|
"sending": "در حال ارسال...",
|
||||||
"sendButton": "ارسال"
|
"sendButton": "ارسال"
|
||||||
},
|
},
|
||||||
"result": {
|
"result": {
|
||||||
"sentTitle": "ارسال شد!",
|
"sentTitle": "ارسال شد!",
|
||||||
"offersCreated": "پیشنهادات ایجاد شده: {{count}}",
|
"offersCreated_other": "پیشنهادات ایجاد شده: {{count}}",
|
||||||
"notificationsSent": "اعلانهای ارسال شده: {{count}}",
|
"notificationsSent_other": "اعلانهای ارسال شده: {{count}}",
|
||||||
"notificationsFailed": "(ناموفق: {{count}})",
|
"notificationsFailed": "(ناموفق: {{count}})",
|
||||||
"sendError": "ارسال پیشنهاد ناموفق بود"
|
"sendError": "ارسال پیشنهاد ناموفق بود"
|
||||||
},
|
},
|
||||||
|
"notFound": "قالب یافت نشد",
|
||||||
|
"noActiveTemplates": "قالب فعالی وجود ندارد",
|
||||||
|
"backToList": "بازگشت به لیست پیشنهادات",
|
||||||
|
"sendAnother": "ارسال دیگری",
|
||||||
"actions": {
|
"actions": {
|
||||||
"created": "ایجاد شده",
|
"created": "ایجاد شده",
|
||||||
"claimed": "فعال شده",
|
"claimed": "فعال شده",
|
||||||
@@ -1588,7 +1712,18 @@
|
|||||||
"offline": "آفلاین",
|
"offline": "آفلاین",
|
||||||
"online": "آنلاین",
|
"online": "آنلاین",
|
||||||
"restart": "راهاندازی مجدد",
|
"restart": "راهاندازی مجدد",
|
||||||
"restartAll": "راهاندازی مجدد همه"
|
"restartAll": "راهاندازی مجدد همه",
|
||||||
|
"usersOnline": "آنلاین",
|
||||||
|
"usersOnlineCount_other": "{{count}} آنلاین",
|
||||||
|
"trafficUsed": "استفاده شده",
|
||||||
|
"uptimeLabel": "زمان فعالیت",
|
||||||
|
"stats": {
|
||||||
|
"total": "کل",
|
||||||
|
"online": "آنلاین",
|
||||||
|
"offline": "آفلاین",
|
||||||
|
"disabled": "غیرفعال",
|
||||||
|
"users": "کاربران"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"bandwidth": "پهنای باند لحظهای",
|
"bandwidth": "پهنای باند لحظهای",
|
||||||
@@ -1602,6 +1737,11 @@
|
|||||||
"total": "کل",
|
"total": "کل",
|
||||||
"totalUsers": "کل کاربران",
|
"totalUsers": "کل کاربران",
|
||||||
"traffic": "آمار ترافیک",
|
"traffic": "آمار ترافیک",
|
||||||
|
"traffic2days": "۲ روز",
|
||||||
|
"traffic7days": "۷ روز",
|
||||||
|
"traffic30days": "۳۰ روز",
|
||||||
|
"trafficMonth": "ماه",
|
||||||
|
"trafficYear": "سال",
|
||||||
"upload": "آپلود",
|
"upload": "آپلود",
|
||||||
"uptime": "زمان فعالیت",
|
"uptime": "زمان فعالیت",
|
||||||
"usersByStatus": "کاربران بر اساس وضعیت",
|
"usersByStatus": "کاربران بر اساس وضعیت",
|
||||||
@@ -1611,23 +1751,48 @@
|
|||||||
"noSquads": "گروهی یافت نشد",
|
"noSquads": "گروهی یافت نشد",
|
||||||
"notSynced": "همگام نشده",
|
"notSynced": "همگام نشده",
|
||||||
"syncServers": "همگامسازی سرورها",
|
"syncServers": "همگامسازی سرورها",
|
||||||
"synced": "همگام شده"
|
"synced": "همگام شده",
|
||||||
|
"members": "اعضا",
|
||||||
|
"membersCount_other": "{{count}} عضو",
|
||||||
|
"inbounds": "ورودیها",
|
||||||
|
"inboundsCount_other": "{{count}} ورودی",
|
||||||
|
"available": "در دسترس",
|
||||||
|
"unavailable": "در دسترس نیست",
|
||||||
|
"stats": {
|
||||||
|
"total": "کل",
|
||||||
|
"synced": "همگام",
|
||||||
|
"available": "در دسترس",
|
||||||
|
"members": "اعضا"
|
||||||
|
},
|
||||||
|
"detail": "جزئیات گروه",
|
||||||
|
"loadError": "بارگذاری گروه ناموفق بود",
|
||||||
|
"info": "اطلاعات",
|
||||||
|
"originalName": "نام اصلی",
|
||||||
|
"countryCode": "کشور",
|
||||||
|
"statsTitle": "آمار",
|
||||||
|
"inboundsList": "لیست ورودیها",
|
||||||
|
"localSettings": "تنظیمات محلی",
|
||||||
|
"trialEligible": "واجد شرایط آزمایشی",
|
||||||
|
"price": "قیمت",
|
||||||
|
"users": "کاربران"
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"autoSync": "همگامسازی خودکار",
|
"autoSync": "همگامسازی خودکار",
|
||||||
"cleanup": "پاکسازی",
|
"runNow": "اجرا",
|
||||||
"cleanupDesc": "حذف اشتراکهای بدون کاربر",
|
"run": "اجرا",
|
||||||
"fromPanel": "همگامسازی از پنل",
|
"running": "در حال اجرا...",
|
||||||
"fromPanelDesc": "وارد کردن کاربران از پنل RemnaWave به پایگاه داده ربات",
|
"schedule": "برنامه",
|
||||||
"manual": "همگامسازی دستی",
|
"status": "وضعیت",
|
||||||
"runNow": "اجرای فوری",
|
"lastRun": "آخرین اجرا",
|
||||||
"statuses": "همگامسازی وضعیتها",
|
"nextRun": "اجرای بعدی",
|
||||||
"statusesDesc": "همگامسازی وضعیت اشتراکها با پنل",
|
"enabled": "فعال",
|
||||||
"subscriptions": "اشتراکها",
|
"disabled": "غیرفعال",
|
||||||
"toPanel": "همگامسازی به پنل",
|
"success": "موفق",
|
||||||
"toPanelDesc": "صادر کردن کاربران از پایگاه داده ربات به پنل RemnaWave",
|
"runAutoSyncNow": "اجرای همگامسازی خودکار",
|
||||||
"validate": "اعتبارسنجی",
|
"fromPanel": "از پنل",
|
||||||
"validateDesc": "بررسی و رفع ناسازگاریهای اشتراک"
|
"fromPanelDesc": "وارد کردن کاربران از RemnaWave به ربات",
|
||||||
|
"toPanel": "به پنل",
|
||||||
|
"toPanelDesc": "صادر کردن کاربران از ربات به RemnaWave"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -1665,7 +1830,7 @@
|
|||||||
"enable": "فعال",
|
"enable": "فعال",
|
||||||
"disable": "غیرفعال",
|
"disable": "غیرفعال",
|
||||||
"hide": "پنهان کردن ({{count}})",
|
"hide": "پنهان کردن ({{count}})",
|
||||||
"showMore": "نمایش {{count}} گره بیشتر"
|
"showMore_other": "نمایش {{count}} گره بیشتر"
|
||||||
},
|
},
|
||||||
"revenue": {
|
"revenue": {
|
||||||
"title": "درآمد",
|
"title": "درآمد",
|
||||||
|
|||||||
@@ -20,11 +20,13 @@
|
|||||||
"and": "и",
|
"and": "и",
|
||||||
"edit": "Редактировать",
|
"edit": "Редактировать",
|
||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
|
"collapse": "Свернуть",
|
||||||
"currency": "₽",
|
"currency": "₽",
|
||||||
"refresh": "Обновить",
|
"refresh": "Обновить",
|
||||||
"copy": "Копировать",
|
"copy": "Копировать",
|
||||||
"retry": "Повторить",
|
"retry": "Повторить",
|
||||||
"saving": "Сохранение...",
|
"saving": "Сохранение...",
|
||||||
|
"understand": "Понятно",
|
||||||
"units": {
|
"units": {
|
||||||
"gb": "ГБ",
|
"gb": "ГБ",
|
||||||
"perGb": "/ГБ"
|
"perGb": "/ГБ"
|
||||||
@@ -433,7 +435,9 @@
|
|||||||
"additionalOptions": {
|
"additionalOptions": {
|
||||||
"title": "Дополнительные опции",
|
"title": "Дополнительные опции",
|
||||||
"buyDevices": "Докупить устройства",
|
"buyDevices": "Докупить устройства",
|
||||||
"currentDeviceLimit": "Текущий лимит: {{count}} устройств",
|
"currentDeviceLimit_one": "Текущий лимит: {{count}} устройство",
|
||||||
|
"currentDeviceLimit_few": "Текущий лимит: {{count}} устройства",
|
||||||
|
"currentDeviceLimit_many": "Текущий лимит: {{count}} устройств",
|
||||||
"maxDevices": "максимум {{count}}",
|
"maxDevices": "максимум {{count}}",
|
||||||
"devicesUnavailable": "Докупка устройств недоступна",
|
"devicesUnavailable": "Докупка устройств недоступна",
|
||||||
"devicesUnit": "устройств",
|
"devicesUnit": "устройств",
|
||||||
@@ -441,13 +445,22 @@
|
|||||||
"reduceDevices": "Уменьшить устройства",
|
"reduceDevices": "Уменьшить устройства",
|
||||||
"reduceDevicesTitle": "Уменьшить лимит устройств",
|
"reduceDevicesTitle": "Уменьшить лимит устройств",
|
||||||
"reduceDevicesDescription": "Вы можете уменьшить количество устройств до минимального на тарифе",
|
"reduceDevicesDescription": "Вы можете уменьшить количество устройств до минимального на тарифе",
|
||||||
"minDeviceLimit": "Минимум на тарифе: {{count}} устройств",
|
"minDeviceLimit_one": "Минимум на тарифе: {{count}} устройство",
|
||||||
"connectedDevices": "Подключено устройств: {{count}}",
|
"minDeviceLimit_few": "Минимум на тарифе: {{count}} устройства",
|
||||||
"disconnectDevicesFirst": "Сначала отключите устройства, чтобы уменьшить лимит ниже {{count}}",
|
"minDeviceLimit_many": "Минимум на тарифе: {{count}} устройств",
|
||||||
|
"connectedDevices_one": "Подключено устройств: {{count}}",
|
||||||
|
"connectedDevices_few": "Подключено устройств: {{count}}",
|
||||||
|
"connectedDevices_many": "Подключено устройств: {{count}}",
|
||||||
|
"disconnectDevicesFirst_one": "Сначала отключите устройства, чтобы уменьшить лимит ниже {{count}} устройства",
|
||||||
|
"disconnectDevicesFirst_few": "Сначала отключите устройства, чтобы уменьшить лимит ниже {{count}} устройств",
|
||||||
|
"disconnectDevicesFirst_many": "Сначала отключите устройства, чтобы уменьшить лимит ниже {{count}} устройств",
|
||||||
"reduceUnavailable": "Уменьшение устройств недоступно",
|
"reduceUnavailable": "Уменьшение устройств недоступно",
|
||||||
|
"alreadyAtMinDeviceLimit": "Уже достигнут минимальный лимит устройств для вашего тарифа",
|
||||||
"reduce": "Уменьшить",
|
"reduce": "Уменьшить",
|
||||||
"reducing": "Уменьшение...",
|
"reducing": "Уменьшение...",
|
||||||
"newDeviceLimit": "Новый лимит: {{count}} устройств",
|
"newDeviceLimit_one": "Новый лимит: {{count}} устройство",
|
||||||
|
"newDeviceLimit_few": "Новый лимит: {{count}} устройства",
|
||||||
|
"newDeviceLimit_many": "Новый лимит: {{count}} устройств",
|
||||||
"buyTraffic": "Докупить трафик",
|
"buyTraffic": "Докупить трафик",
|
||||||
"currentTrafficLimit": "Текущий лимит: {{limit}} ГБ (использовано {{used}} ГБ)",
|
"currentTrafficLimit": "Текущий лимит: {{limit}} ГБ (использовано {{used}} ГБ)",
|
||||||
"buyTrafficTitle": "Докупить трафик",
|
"buyTrafficTitle": "Докупить трафик",
|
||||||
@@ -692,6 +705,7 @@
|
|||||||
"wheel": {
|
"wheel": {
|
||||||
"title": "Колесо удачи",
|
"title": "Колесо удачи",
|
||||||
"disabled": "Колесо удачи временно недоступно",
|
"disabled": "Колесо удачи временно недоступно",
|
||||||
|
"prizes": "Призы",
|
||||||
"spinsRemaining": "Осталось вращений сегодня",
|
"spinsRemaining": "Осталось вращений сегодня",
|
||||||
"choosePayment": "Выберите способ оплаты",
|
"choosePayment": "Выберите способ оплаты",
|
||||||
"stars": "Stars",
|
"stars": "Stars",
|
||||||
@@ -755,7 +769,8 @@
|
|||||||
"paymentMethods": "Платёжные методы",
|
"paymentMethods": "Платёжные методы",
|
||||||
"campaigns": "Кампании",
|
"campaigns": "Кампании",
|
||||||
"promoOffers": "Промопредложения",
|
"promoOffers": "Промопредложения",
|
||||||
"promocodes": "Промокоды"
|
"promocodes": "Промокоды",
|
||||||
|
"promoGroups": "Группы скидок"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "Панель администратора",
|
"title": "Панель администратора",
|
||||||
@@ -776,7 +791,8 @@
|
|||||||
"paymentMethodsDesc": "Настройка и порядок платежек",
|
"paymentMethodsDesc": "Настройка и порядок платежек",
|
||||||
"campaignsDesc": "Рекламные кампании",
|
"campaignsDesc": "Рекламные кампании",
|
||||||
"promoOffersDesc": "Персональные скидки",
|
"promoOffersDesc": "Персональные скидки",
|
||||||
"promocodesDesc": "Управление промокодами"
|
"promocodesDesc": "Управление промокодами",
|
||||||
|
"promoGroupsDesc": "Группы скидок для пользователей"
|
||||||
},
|
},
|
||||||
"emailTemplates": {
|
"emailTemplates": {
|
||||||
"title": "Email-шаблоны",
|
"title": "Email-шаблоны",
|
||||||
@@ -796,7 +812,9 @@
|
|||||||
"resetted": "Шаблон сброшен к значению по умолчанию",
|
"resetted": "Шаблон сброшен к значению по умолчанию",
|
||||||
"testSent": "Тестовое письмо отправлено",
|
"testSent": "Тестовое письмо отправлено",
|
||||||
"variables": "Доступные переменные",
|
"variables": "Доступные переменные",
|
||||||
"clickToCopy": "Нажмите для копирования"
|
"clickToCopy": "Нажмите для копирования",
|
||||||
|
"previewDesktopOnly": "Предпросмотр доступен только в веб-версии на десктопе",
|
||||||
|
"previewNotAvailable": "Предпросмотр недоступен"
|
||||||
},
|
},
|
||||||
"payments": {
|
"payments": {
|
||||||
"title": "Проверка платежей",
|
"title": "Проверка платежей",
|
||||||
@@ -877,6 +895,11 @@
|
|||||||
"memory": "Память",
|
"memory": "Память",
|
||||||
"uptime": "Аптайм",
|
"uptime": "Аптайм",
|
||||||
"traffic": "Статистика трафика",
|
"traffic": "Статистика трафика",
|
||||||
|
"traffic2days": "2 дня",
|
||||||
|
"traffic7days": "7 дней",
|
||||||
|
"traffic30days": "30 дней",
|
||||||
|
"trafficMonth": "Месяц",
|
||||||
|
"trafficYear": "Год",
|
||||||
"usersByStatus": "Пользователи по статусу"
|
"usersByStatus": "Пользователи по статусу"
|
||||||
},
|
},
|
||||||
"nodes": {
|
"nodes": {
|
||||||
@@ -888,29 +911,71 @@
|
|||||||
"enable": "Включить",
|
"enable": "Включить",
|
||||||
"disable": "Отключить",
|
"disable": "Отключить",
|
||||||
"restartAll": "Перезапустить все",
|
"restartAll": "Перезапустить все",
|
||||||
"confirmRestartAll": "Вы уверены, что хотите перезапустить все ноды?"
|
"confirmRestartAll": "Вы уверены, что хотите перезапустить все ноды?",
|
||||||
|
"usersOnline": "онлайн",
|
||||||
|
"usersOnlineCount_one": "{{count}} онлайн",
|
||||||
|
"usersOnlineCount_few": "{{count}} онлайн",
|
||||||
|
"usersOnlineCount_many": "{{count}} онлайн",
|
||||||
|
"trafficUsed": "использовано",
|
||||||
|
"uptimeLabel": "Аптайм",
|
||||||
|
"stats": {
|
||||||
|
"total": "Всего",
|
||||||
|
"online": "Онлайн",
|
||||||
|
"offline": "Офлайн",
|
||||||
|
"disabled": "Отключены",
|
||||||
|
"users": "Пользователей"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"squads": {
|
"squads": {
|
||||||
"synced": "Синхронизирован",
|
"synced": "Синхронизирован",
|
||||||
"notSynced": "Не синхронизирован",
|
"notSynced": "Не синхронизирован",
|
||||||
"noSquads": "Сквады не найдены",
|
"noSquads": "Сквады не найдены",
|
||||||
"syncServers": "Синхронизировать серверы"
|
"syncServers": "Синхронизировать серверы",
|
||||||
|
"members": "участников",
|
||||||
|
"membersCount_one": "{{count}} участник",
|
||||||
|
"membersCount_few": "{{count}} участника",
|
||||||
|
"membersCount_many": "{{count}} участников",
|
||||||
|
"inbounds": "inbounds",
|
||||||
|
"inboundsCount_one": "{{count}} inbound",
|
||||||
|
"inboundsCount_few": "{{count}} inbounds",
|
||||||
|
"inboundsCount_many": "{{count}} inbounds",
|
||||||
|
"available": "Доступен",
|
||||||
|
"unavailable": "Недоступен",
|
||||||
|
"stats": {
|
||||||
|
"total": "Всего",
|
||||||
|
"synced": "Синхр.",
|
||||||
|
"available": "Доступно",
|
||||||
|
"members": "Участников"
|
||||||
|
},
|
||||||
|
"detail": "Детали сквада",
|
||||||
|
"loadError": "Не удалось загрузить сквад",
|
||||||
|
"info": "Информация",
|
||||||
|
"originalName": "Оригинальное имя",
|
||||||
|
"countryCode": "Страна",
|
||||||
|
"statsTitle": "Статистика",
|
||||||
|
"inboundsList": "Inbounds",
|
||||||
|
"localSettings": "Локальные настройки",
|
||||||
|
"trialEligible": "Доступен триал",
|
||||||
|
"price": "Цена",
|
||||||
|
"users": "Пользователей"
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"autoSync": "Автосинхронизация",
|
"autoSync": "Автосинхронизация",
|
||||||
"runNow": "Запустить сейчас",
|
"runNow": "Запустить",
|
||||||
"manual": "Ручная синхронизация",
|
"run": "Запустить",
|
||||||
"fromPanel": "Синхронизация из панели",
|
"running": "Выполняется...",
|
||||||
"fromPanelDesc": "Импорт пользователей из RemnaWave панели в базу бота",
|
"schedule": "Расписание",
|
||||||
"toPanel": "Синхронизация в панель",
|
"status": "Статус",
|
||||||
"toPanelDesc": "Экспорт пользователей из базы бота в RemnaWave панель",
|
"lastRun": "Посл. запуск",
|
||||||
"subscriptions": "Подписки",
|
"nextRun": "След. запуск",
|
||||||
"validate": "Валидация",
|
"enabled": "Включено",
|
||||||
"validateDesc": "Проверка и исправление несоответствий подписок",
|
"disabled": "Выключено",
|
||||||
"cleanup": "Очистка",
|
"success": "Успешно",
|
||||||
"cleanupDesc": "Удаление orphaned подписок без пользователей",
|
"runAutoSyncNow": "Запустить автосинхронизацию",
|
||||||
"statuses": "Синхронизация статусов",
|
"fromPanel": "Из панели",
|
||||||
"statusesDesc": "Синхронизация статусов подписок с панелью"
|
"fromPanelDesc": "Импорт пользователей из RemnaWave в бота",
|
||||||
|
"toPanel": "В панель",
|
||||||
|
"toPanelDesc": "Экспорт пользователей из бота в RemnaWave"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"wheel": {
|
"wheel": {
|
||||||
@@ -922,21 +987,29 @@
|
|||||||
"prizes": "Призы",
|
"prizes": "Призы",
|
||||||
"statistics": "Статистика"
|
"statistics": "Статистика"
|
||||||
},
|
},
|
||||||
|
"preview": "Предпросмотр",
|
||||||
"settings": {
|
"settings": {
|
||||||
"enableWheel": "Включить колесо",
|
"enableWheel": "Включить колесо",
|
||||||
"allowSpins": "Разрешить пользователям крутить колесо",
|
"allowSpins": "Разрешить пользователям крутить колесо",
|
||||||
|
"spinCost": "Стоимость вращения",
|
||||||
"costInStars": "Стоимость в Stars",
|
"costInStars": "Стоимость в Stars",
|
||||||
"costInDays": "Стоимость в днях",
|
"costInDays": "Стоимость в днях",
|
||||||
|
"limitsAndRtp": "Лимиты и RTP",
|
||||||
"rtpPercent": "RTP (Return to Player) %",
|
"rtpPercent": "RTP (Return to Player) %",
|
||||||
"dailyLimit": "Дневной лимит вращений (0 = безлимит)",
|
"dailyLimit": "Дневной лимит вращений (0 = безлимит)",
|
||||||
"minSubDays": "Мин. дней подписки для оплаты днями",
|
"minSubDays": "Мин. дней подписки для оплаты днями",
|
||||||
|
"promocodes": "Промокоды",
|
||||||
"promoPrefix": "Префикс промокодов"
|
"promoPrefix": "Префикс промокодов"
|
||||||
},
|
},
|
||||||
"prizes": {
|
"prizes": {
|
||||||
"addPrize": "Добавить приз",
|
"addPrize": "Добавить приз",
|
||||||
"editPrize": "Редактировать приз",
|
"editPrize": "Редактировать приз",
|
||||||
"noPrizes": "Призы не настроены. Добавьте призы для активации колеса.",
|
"noPrizes": "Призы не настроены. Добавьте призы для активации колеса.",
|
||||||
"deletePrize": "Удалить этот приз?",
|
"deletePrize": "Удалить приз",
|
||||||
|
"confirmDelete": "Вы уверены, что хотите удалить этот приз? Это действие нельзя отменить.",
|
||||||
|
"dragToReorder": "Перетащите для изменения порядка",
|
||||||
|
"unsavedOrder": "Есть несохраненные изменения порядка",
|
||||||
|
"unsavedOrderHint": "Сохраните изменения или отмените их",
|
||||||
"types": {
|
"types": {
|
||||||
"subscription_days": "Дни подписки",
|
"subscription_days": "Дни подписки",
|
||||||
"balance_bonus": "Бонус баланса",
|
"balance_bonus": "Бонус баланса",
|
||||||
@@ -999,6 +1072,9 @@
|
|||||||
"filter": "Фильтр",
|
"filter": "Фильтр",
|
||||||
"message": "Сообщение",
|
"message": "Сообщение",
|
||||||
"unknownAdmin": "Неизвестный админ",
|
"unknownAdmin": "Неизвестный админ",
|
||||||
|
"detail": "Рассылка",
|
||||||
|
"createdBy": "Создал",
|
||||||
|
"notFound": "Рассылка не найдена",
|
||||||
"prev": "Назад",
|
"prev": "Назад",
|
||||||
"next": "Далее",
|
"next": "Далее",
|
||||||
"status": {
|
"status": {
|
||||||
@@ -1681,6 +1757,8 @@
|
|||||||
"replyPlaceholder": "Введите ваш ответ...",
|
"replyPlaceholder": "Введите ваш ответ...",
|
||||||
"sendReply": "Отправить",
|
"sendReply": "Отправить",
|
||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
|
"settingsSubtitle": "Настройка тикетной системы и SLA",
|
||||||
|
"settingsLoadError": "Не удалось загрузить настройки",
|
||||||
"supportMode": "Режим поддержки",
|
"supportMode": "Режим поддержки",
|
||||||
"supportModeDesc": "Выберите, как пользователи могут связаться с поддержкой",
|
"supportModeDesc": "Выберите, как пользователи могут связаться с поддержкой",
|
||||||
"modeBoth": "Тикеты и контакт",
|
"modeBoth": "Тикеты и контакт",
|
||||||
@@ -1738,7 +1816,9 @@
|
|||||||
"toggleTrial": "Переключить триал",
|
"toggleTrial": "Переключить триал",
|
||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
"confirmDelete": "Удалить тариф?",
|
"confirmDelete": "Удалить тариф?",
|
||||||
"confirmDeleteText": "Это действие нельзя отменить. Тариф будет удален навсегда.",
|
"confirmDeleteText": "Это действие нельзя отменить. Тариф будет удалён навсегда.",
|
||||||
|
"confirmDeleteWithSubscriptions": "У этого тарифа {{count}} активных подписок. При удалении пользователям потребуется выбрать новый тариф при продлении.",
|
||||||
|
"deleteSuccess": "Тариф успешно удален",
|
||||||
"days": "дней",
|
"days": "дней",
|
||||||
"days_one": "{{count}} день",
|
"days_one": "{{count}} день",
|
||||||
"days_few": "{{count}} дня",
|
"days_few": "{{count}} дня",
|
||||||
@@ -1755,6 +1835,7 @@
|
|||||||
"serverTrafficLimit": "Лимит трафика",
|
"serverTrafficLimit": "Лимит трафика",
|
||||||
"useDefault": "по умолчанию",
|
"useDefault": "по умолчанию",
|
||||||
"selectType": "Выберите тип тарифа",
|
"selectType": "Выберите тип тарифа",
|
||||||
|
"selectTypeDesc": "Выберите способ оплаты для нового тарифа",
|
||||||
"periodTariff": "Периодный тариф",
|
"periodTariff": "Периодный тариф",
|
||||||
"periodTariffDesc": "Оплата за период (7, 30, 90 дней и т.д.). Произвольные периоды и цены.",
|
"periodTariffDesc": "Оплата за период (7, 30, 90 дней и т.д.). Произвольные периоды и цены.",
|
||||||
"dailyTariff": "Суточный тариф",
|
"dailyTariff": "Суточный тариф",
|
||||||
@@ -1771,6 +1852,7 @@
|
|||||||
"nameLabel": "Название тарифа",
|
"nameLabel": "Название тарифа",
|
||||||
"nameExamplePeriod": "Например: Стандарт",
|
"nameExamplePeriod": "Например: Стандарт",
|
||||||
"nameExampleDaily": "Например: Суточный",
|
"nameExampleDaily": "Например: Суточный",
|
||||||
|
"nameHint": "От 2 до 50 символов",
|
||||||
"descriptionLabel": "Описание",
|
"descriptionLabel": "Описание",
|
||||||
"descriptionPlaceholder": "Краткое описание тарифа",
|
"descriptionPlaceholder": "Краткое описание тарифа",
|
||||||
"trafficLimitLabel": "Лимит трафика",
|
"trafficLimitLabel": "Лимит трафика",
|
||||||
@@ -1779,6 +1861,7 @@
|
|||||||
"deviceLimitLabel": "Устройств в тарифе",
|
"deviceLimitLabel": "Устройств в тарифе",
|
||||||
"tierLevelLabel": "Уровень тарифа",
|
"tierLevelLabel": "Уровень тарифа",
|
||||||
"tierLevelHint": "Влияет на доступность перехода между тарифами",
|
"tierLevelHint": "Влияет на доступность перехода между тарифами",
|
||||||
|
"fieldRequired": "Обязательное поле",
|
||||||
"periodsTabHint": "Добавьте периоды и цены для тарифа. Пользователи смогут выбирать из добавленных периодов.",
|
"periodsTabHint": "Добавьте периоды и цены для тарифа. Пользователи смогут выбирать из добавленных периодов.",
|
||||||
"addPeriodTitle": "Добавить период",
|
"addPeriodTitle": "Добавить период",
|
||||||
"daysLabel": "Дней",
|
"daysLabel": "Дней",
|
||||||
@@ -1786,8 +1869,12 @@
|
|||||||
"addButton": "Добавить",
|
"addButton": "Добавить",
|
||||||
"noPeriodsHint": "Нет добавленных периодов. Добавьте хотя бы один период.",
|
"noPeriodsHint": "Нет добавленных периодов. Добавьте хотя бы один период.",
|
||||||
"daysShort": "дн.",
|
"daysShort": "дн.",
|
||||||
|
"serversTitle": "Серверы",
|
||||||
"serversTabHint": "Выберите серверы, доступные на этом тарифе.",
|
"serversTabHint": "Выберите серверы, доступные на этом тарифе.",
|
||||||
"noServersAvailable": "Нет доступных серверов",
|
"noServersAvailable": "Нет доступных серверов",
|
||||||
|
"promoGroupsTitle": "Промо-группы",
|
||||||
|
"promoGroupsHint": "Выберите промо-группы, которым доступен этот тариф. Если не выбрано ничего — доступен всем.",
|
||||||
|
"noPromoGroups": "Нет промо-групп",
|
||||||
"extraDeviceTitle": "Докупка устройств",
|
"extraDeviceTitle": "Докупка устройств",
|
||||||
"devicePriceLabel": "Цена за устройство (30 дней):",
|
"devicePriceLabel": "Цена за устройство (30 дней):",
|
||||||
"devicePriceHint": "0 = докупка недоступна",
|
"devicePriceHint": "0 = докупка недоступна",
|
||||||
@@ -1797,6 +1884,8 @@
|
|||||||
"trafficMaxLimitLabel": "Макс. лимит:",
|
"trafficMaxLimitLabel": "Макс. лимит:",
|
||||||
"trafficLimitHint2": "(0 = без ограничений)",
|
"trafficLimitHint2": "(0 = без ограничений)",
|
||||||
"trafficPackagesLabel": "Пакеты трафика:",
|
"trafficPackagesLabel": "Пакеты трафика:",
|
||||||
|
"addPackageTitle": "Добавить пакет",
|
||||||
|
"noPackagesHint": "Нет добавленных пакетов. Добавьте хотя бы один пакет.",
|
||||||
"gbPackageUnit": "ГБ:",
|
"gbPackageUnit": "ГБ:",
|
||||||
"customDaysTitle": "Произвольное количество дней",
|
"customDaysTitle": "Произвольное количество дней",
|
||||||
"customDaysDesc": "Пользователь сам выбирает срок подписки",
|
"customDaysDesc": "Пользователь сам выбирает срок подписки",
|
||||||
@@ -1827,7 +1916,24 @@
|
|||||||
"periodType": "Периодный",
|
"periodType": "Периодный",
|
||||||
"dailyPriceLabel": "Цена за день",
|
"dailyPriceLabel": "Цена за день",
|
||||||
"currencyPerDay": "₽/день",
|
"currencyPerDay": "₽/день",
|
||||||
"dailyDeductionDesc": "Списывается ежедневно с баланса пользователя"
|
"dailyDeductionDesc": "Списывается ежедневно с баланса пользователя",
|
||||||
|
"dailyPriceRequired": "Укажите цену за день больше 0",
|
||||||
|
"cannotSave": "Невозможно сохранить тариф:",
|
||||||
|
"statusTitle": "Статус тарифа",
|
||||||
|
"isActiveLabel": "Активен",
|
||||||
|
"isActiveHint": "Неактивные тарифы не отображаются пользователям",
|
||||||
|
"isTrialLabel": "Триальный тариф",
|
||||||
|
"isTrialHint": "Доступен для бесплатного пробного периода",
|
||||||
|
"validation": {
|
||||||
|
"nameRequired": "Введите название тарифа",
|
||||||
|
"nameLength": "Название должно быть от 2 до 50 символов",
|
||||||
|
"deviceLimitRequired": "Укажите количество устройств (минимум 1)",
|
||||||
|
"tierLevelInvalid": "Уровень тарифа должен быть от 1 до 10",
|
||||||
|
"fieldsRequired": "Заполните обязательные поля (устройства, уровень)",
|
||||||
|
"periodsRequired": "Добавьте хотя бы один период",
|
||||||
|
"dailyPriceRequired": "Укажите цену за день больше 0",
|
||||||
|
"trafficPackagesRequired": "Добавьте хотя бы один пакет трафика (или отключите докупку)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"servers": {
|
"servers": {
|
||||||
"title": "Управление серверами",
|
"title": "Управление серверами",
|
||||||
@@ -1858,7 +1964,10 @@
|
|||||||
"enable": "Включить",
|
"enable": "Включить",
|
||||||
"disable": "Отключить",
|
"disable": "Отключить",
|
||||||
"toggleTrial": "Переключить триал",
|
"toggleTrial": "Переключить триал",
|
||||||
"loadError": "Не удалось загрузить сервер"
|
"loadError": "Не удалось загрузить сервер",
|
||||||
|
"mainSettings": "Основные настройки",
|
||||||
|
"pricingAndLimits": "Цены и лимиты",
|
||||||
|
"updateError": "Не удалось сохранить изменения"
|
||||||
},
|
},
|
||||||
"campaigns": {
|
"campaigns": {
|
||||||
"title": "Рекламные кампании",
|
"title": "Рекламные кампании",
|
||||||
@@ -1879,7 +1988,9 @@
|
|||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"inactive": "Неактивна",
|
"inactive": "Неактивна",
|
||||||
"registrations": "{{count}} регистраций",
|
"registrations_one": "{{count}} регистрация",
|
||||||
|
"registrations_few": "{{count}} регистрации",
|
||||||
|
"registrations_many": "{{count}} регистраций",
|
||||||
"revenue": "{{amount}} доход",
|
"revenue": "{{amount}} доход",
|
||||||
"conversion": "{{rate}}% конверсия",
|
"conversion": "{{rate}}% конверсия",
|
||||||
"statistics": "Статистика",
|
"statistics": "Статистика",
|
||||||
@@ -1917,13 +2028,20 @@
|
|||||||
"saving": "Сохранение..."
|
"saving": "Сохранение..."
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
|
"title": "Статистика кампании",
|
||||||
|
"loadError": "Не удалось загрузить статистику",
|
||||||
|
"detailedStats": "Детальная статистика",
|
||||||
"registrations": "Регистраций",
|
"registrations": "Регистраций",
|
||||||
"revenue": "Доход",
|
"revenue": "Доход",
|
||||||
"paidUsers": "Оплатили",
|
"paidUsers": "Оплатили",
|
||||||
"conversion": "Конверсия",
|
"conversion": "Конверсия",
|
||||||
"bonusesIssued": "Бонусы выданы",
|
"bonusesIssued": "Бонусы выданы",
|
||||||
"subscriptionsIssued": "{{count}} подписок",
|
"subscriptionsIssued_one": "{{count}} подписка",
|
||||||
"tariffsIssued": "{{count}} тарифов",
|
"subscriptionsIssued_few": "{{count}} подписки",
|
||||||
|
"subscriptionsIssued_many": "{{count}} подписок",
|
||||||
|
"tariffsIssued_one": "{{count}} тариф",
|
||||||
|
"tariffsIssued_few": "{{count}} тарифа",
|
||||||
|
"tariffsIssued_many": "{{count}} тарифов",
|
||||||
"avgRevenuePerUser": "Средний доход с пользователя",
|
"avgRevenuePerUser": "Средний доход с пользователя",
|
||||||
"avgFirstPayment": "Средний первый платёж",
|
"avgFirstPayment": "Средний первый платёж",
|
||||||
"trialSubscriptions": "Пробных подписок",
|
"trialSubscriptions": "Пробных подписок",
|
||||||
@@ -1934,7 +2052,10 @@
|
|||||||
"inactive": "Неактивна",
|
"inactive": "Неактивна",
|
||||||
"copy": "Копировать",
|
"copy": "Копировать",
|
||||||
"copied": "Скопировано!",
|
"copied": "Скопировано!",
|
||||||
"users": "Пользователи"
|
"users": "Пользователи",
|
||||||
|
"noUsers": "Нет зарегистрированных пользователей",
|
||||||
|
"paid": "Платил",
|
||||||
|
"hasSub": "Подписка"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"title": "Пользователи кампании",
|
"title": "Пользователи кампании",
|
||||||
@@ -1956,7 +2077,9 @@
|
|||||||
"deleteText": "Это действие нельзя отменить. Кампании с регистрациями удалить нельзя.",
|
"deleteText": "Это действие нельзя отменить. Кампании с регистрациями удалить нельзя.",
|
||||||
"cancel": "Отмена",
|
"cancel": "Отмена",
|
||||||
"delete": "Удалить"
|
"delete": "Удалить"
|
||||||
}
|
},
|
||||||
|
"loadError": "Не удалось загрузить кампанию",
|
||||||
|
"updateError": "Не удалось сохранить изменения"
|
||||||
},
|
},
|
||||||
"promocodes": {
|
"promocodes": {
|
||||||
"title": "Промокоды",
|
"title": "Промокоды",
|
||||||
@@ -1970,8 +2093,12 @@
|
|||||||
"until": "До",
|
"until": "До",
|
||||||
"discountForHours": "-{{percent}}% на {{hours}}ч",
|
"discountForHours": "-{{percent}}% на {{hours}}ч",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"promocodes": "Промокоды ({{count}})",
|
"promocodes_one": "Промокоды ({{count}} промокод)",
|
||||||
"groups": "Группы скидок ({{count}})"
|
"promocodes_few": "Промокоды ({{count}} промокода)",
|
||||||
|
"promocodes_many": "Промокоды ({{count}} промокодов)",
|
||||||
|
"groups_one": "Группы скидок ({{count}} группа)",
|
||||||
|
"groups_few": "Группы скидок ({{count}} группы)",
|
||||||
|
"groups_many": "Группы скидок ({{count}} групп)"
|
||||||
},
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"balance": "Баланс",
|
"balance": "Баланс",
|
||||||
@@ -2018,6 +2145,8 @@
|
|||||||
"save": "Сохранить"
|
"save": "Сохранить"
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
|
"title": "Статистика промокода",
|
||||||
|
"notFound": "Промокод не найден",
|
||||||
"totalUses": "Всего использований",
|
"totalUses": "Всего использований",
|
||||||
"today": "Сегодня",
|
"today": "Сегодня",
|
||||||
"remaining": "Осталось",
|
"remaining": "Осталось",
|
||||||
@@ -2027,7 +2156,9 @@
|
|||||||
"daysLabel": "Дней",
|
"daysLabel": "Дней",
|
||||||
"discountLabel": "Скидка",
|
"discountLabel": "Скидка",
|
||||||
"validFor": "Действует",
|
"validFor": "Действует",
|
||||||
"hoursValue": "{{count}} часов",
|
"hoursValue_one": "{{count}} час",
|
||||||
|
"hoursValue_few": "{{count}} часа",
|
||||||
|
"hoursValue_many": "{{count}} часов",
|
||||||
"limit": "Лимит",
|
"limit": "Лимит",
|
||||||
"status": "Статус",
|
"status": "Статус",
|
||||||
"active": "Активен",
|
"active": "Активен",
|
||||||
@@ -2068,7 +2199,9 @@
|
|||||||
"default": "По умолчанию",
|
"default": "По умолчанию",
|
||||||
"daysShort": "{{days}} дн.",
|
"daysShort": "{{days}} дн.",
|
||||||
"autoFrom": "Авто от {{amount}} руб.",
|
"autoFrom": "Авто от {{amount}} руб.",
|
||||||
"members": "{{count}} участников"
|
"members_one": "{{count}} участник",
|
||||||
|
"members_few": "{{count}} участника",
|
||||||
|
"members_many": "{{count}} участников"
|
||||||
},
|
},
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"deletePromocode": "Удалить промокод?",
|
"deletePromocode": "Удалить промокод?",
|
||||||
@@ -2076,6 +2209,62 @@
|
|||||||
"deletePromocodeText": "Промокод будет удален безвозвратно.",
|
"deletePromocodeText": "Промокод будет удален безвозвратно.",
|
||||||
"deleteGroupText": "Группа будет удалена. Пользователи потеряют скидки этой группы.",
|
"deleteGroupText": "Группа будет удалена. Пользователи потеряют скидки этой группы.",
|
||||||
"deleteButton": "Удалить"
|
"deleteButton": "Удалить"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"codeRequired": "Введите код промокода",
|
||||||
|
"balanceRequired": "Сумма бонуса должна быть больше 0",
|
||||||
|
"daysRequired": "Количество дней должно быть больше 0",
|
||||||
|
"groupRequired": "Выберите группу скидок",
|
||||||
|
"discountPercentInvalid": "Процент скидки должен быть от 1 до 100",
|
||||||
|
"discountHoursRequired": "Укажите время действия скидки в часах"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"promoGroups": {
|
||||||
|
"title": "Группы скидок",
|
||||||
|
"subtitle": "Управление группами скидок для пользователей",
|
||||||
|
"addGroup": "Добавить группу",
|
||||||
|
"noGroups": "Нет групп скидок",
|
||||||
|
"default": "По умолчанию",
|
||||||
|
"servers": "Серверы",
|
||||||
|
"traffic": "Трафик",
|
||||||
|
"devices": "Устройства",
|
||||||
|
"daysShort": "{{days}} дн.",
|
||||||
|
"autoFrom": "Авто от {{amount}} руб.",
|
||||||
|
"members": "{{count}} участников",
|
||||||
|
"createTitle": "Создание группы скидок",
|
||||||
|
"editTitle": "Редактирование группы",
|
||||||
|
"stats": {
|
||||||
|
"total": "Всего групп",
|
||||||
|
"members": "Участников",
|
||||||
|
"autoAssign": "С авто-назначением"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"edit": "Редактировать",
|
||||||
|
"delete": "Удалить"
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"title": "Удалить группу?",
|
||||||
|
"text": "Группа будет удалена. Пользователи потеряют скидки этой группы.",
|
||||||
|
"cancel": "Отмена",
|
||||||
|
"delete": "Удалить"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"name": "Название группы",
|
||||||
|
"namePlaceholder": "VIP клиенты",
|
||||||
|
"categoryDiscounts": "Скидки по категориям",
|
||||||
|
"periodDiscounts": "Скидки по периодам",
|
||||||
|
"add": "Добавить",
|
||||||
|
"periodHint": "Скидка применяется при покупке подписки на указанное кол-во дней",
|
||||||
|
"noPeriods": "Нет скидок по периодам",
|
||||||
|
"daysPlaceholder": "Дни",
|
||||||
|
"arrow": "дней →",
|
||||||
|
"autoAssign": "Авто-назначение при тратах от",
|
||||||
|
"rub": "руб.",
|
||||||
|
"autoAssignHint": "0 = не назначать автоматически",
|
||||||
|
"applyToAddons": "Применять к дополнительным услугам",
|
||||||
|
"cancel": "Отмена",
|
||||||
|
"saving": "Сохранение...",
|
||||||
|
"save": "Сохранить"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
@@ -2084,7 +2273,9 @@
|
|||||||
"search": "Поиск по ID, имени, username...",
|
"search": "Поиск по ID, имени, username...",
|
||||||
"searchEmail": "Поиск по email...",
|
"searchEmail": "Поиск по email...",
|
||||||
"noData": "Пользователи не найдены",
|
"noData": "Пользователи не найдены",
|
||||||
"purchaseCount": "{{count}} покупок",
|
"purchaseCount_one": "{{count}} покупка",
|
||||||
|
"purchaseCount_few": "{{count}} покупки",
|
||||||
|
"purchaseCount_many": "{{count}} покупок",
|
||||||
"noPurchases": "Нет покупок",
|
"noPurchases": "Нет покупок",
|
||||||
"stats": {
|
"stats": {
|
||||||
"total": "Всего",
|
"total": "Всего",
|
||||||
@@ -2224,7 +2415,9 @@
|
|||||||
"subtitle": "Шаблоны, рассылка и логи предложений",
|
"subtitle": "Шаблоны, рассылка и логи предложений",
|
||||||
"sendButton": "Отправить",
|
"sendButton": "Отправить",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"templates": "Шаблоны ({{count}})",
|
"templates_one": "Шаблоны ({{count}} шаблон)",
|
||||||
|
"templates_few": "Шаблоны ({{count}} шаблона)",
|
||||||
|
"templates_many": "Шаблоны ({{count}} шаблонов)",
|
||||||
"logs": "Логи"
|
"logs": "Логи"
|
||||||
},
|
},
|
||||||
"noData": {
|
"noData": {
|
||||||
@@ -2266,17 +2459,30 @@
|
|||||||
"segment": "Сегмент",
|
"segment": "Сегмент",
|
||||||
"user": "Пользователь",
|
"user": "Пользователь",
|
||||||
"userIdPlaceholder": "Telegram ID или User ID",
|
"userIdPlaceholder": "Telegram ID или User ID",
|
||||||
|
"searchUserPlaceholder": "Поиск по имени, username или Telegram ID...",
|
||||||
|
"noUsersFound": "Пользователи не найдены",
|
||||||
|
"hasSubscription": "Подп",
|
||||||
"preview": "Предпросмотр",
|
"preview": "Предпросмотр",
|
||||||
"sending": "Отправка...",
|
"sending": "Отправка...",
|
||||||
"sendButton": "Отправить"
|
"sendButton": "Отправить"
|
||||||
},
|
},
|
||||||
"result": {
|
"result": {
|
||||||
"sentTitle": "Отправлено!",
|
"sentTitle": "Отправлено!",
|
||||||
"offersCreated": "Создано предложений: {{count}}",
|
"offersCreated_one": "Создано предложение: {{count}}",
|
||||||
"notificationsSent": "Уведомлений отправлено: {{count}}",
|
"offersCreated_few": "Создано предложения: {{count}}",
|
||||||
"notificationsFailed": "(не доставлено: {{count}})",
|
"offersCreated_many": "Создано предложений: {{count}}",
|
||||||
|
"notificationsSent_one": "Уведомление отправлено: {{count}}",
|
||||||
|
"notificationsSent_few": "Уведомления отправлено: {{count}}",
|
||||||
|
"notificationsSent_many": "Уведомлений отправлено: {{count}}",
|
||||||
|
"notificationsFailed_one": "(не доставлено: {{count}})",
|
||||||
|
"notificationsFailed_few": "(не доставлено: {{count}})",
|
||||||
|
"notificationsFailed_many": "(не доставлено: {{count}})",
|
||||||
"sendError": "Не удалось отправить предложение"
|
"sendError": "Не удалось отправить предложение"
|
||||||
},
|
},
|
||||||
|
"notFound": "Шаблон не найден",
|
||||||
|
"noActiveTemplates": "Нет активных шаблонов",
|
||||||
|
"backToList": "К списку предложений",
|
||||||
|
"sendAnother": "Отправить еще",
|
||||||
"actions": {
|
"actions": {
|
||||||
"created": "Создано",
|
"created": "Создано",
|
||||||
"claimed": "Активировано",
|
"claimed": "Активировано",
|
||||||
@@ -2352,7 +2558,9 @@
|
|||||||
"enable": "Включить",
|
"enable": "Включить",
|
||||||
"disable": "Отключить",
|
"disable": "Отключить",
|
||||||
"hide": "Скрыть ({{count}})",
|
"hide": "Скрыть ({{count}})",
|
||||||
"showMore": "Показать еще {{count}} нод"
|
"showMore_one": "Показать еще {{count}} ноду",
|
||||||
|
"showMore_few": "Показать еще {{count}} ноды",
|
||||||
|
"showMore_many": "Показать еще {{count}} нод"
|
||||||
},
|
},
|
||||||
"revenue": {
|
"revenue": {
|
||||||
"title": "Доходы",
|
"title": "Доходы",
|
||||||
@@ -2391,7 +2599,9 @@
|
|||||||
},
|
},
|
||||||
"topReferrers": {
|
"topReferrers": {
|
||||||
"title": "Топ рефералов",
|
"title": "Топ рефералов",
|
||||||
"stats": "реф., {{count}} пригл.",
|
"stats_one": "реф., {{count}} пригл.",
|
||||||
|
"stats_few": "реф., {{count}} пригл.",
|
||||||
|
"stats_many": "реф., {{count}} пригл.",
|
||||||
"byEarnings": "По доходу",
|
"byEarnings": "По доходу",
|
||||||
"byInvited": "По пригл.",
|
"byInvited": "По пригл.",
|
||||||
"invites": "пригл.",
|
"invites": "пригл.",
|
||||||
@@ -2404,7 +2614,9 @@
|
|||||||
},
|
},
|
||||||
"topCampaigns": {
|
"topCampaigns": {
|
||||||
"title": "Топ РК ссылок",
|
"title": "Топ РК ссылок",
|
||||||
"stats": "камп., {{count}} рег.",
|
"stats_one": "камп., {{count}} рег.",
|
||||||
|
"stats_few": "камп., {{count}} рег.",
|
||||||
|
"stats_many": "камп., {{count}} рег.",
|
||||||
"total": "Всего от РК"
|
"total": "Всего от РК"
|
||||||
},
|
},
|
||||||
"recentPayments": {
|
"recentPayments": {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
"retry": "重试",
|
"retry": "重试",
|
||||||
"saving": "保存中...",
|
"saving": "保存中...",
|
||||||
|
"understand": "明白了",
|
||||||
|
"collapse": "收起",
|
||||||
"units": {
|
"units": {
|
||||||
"gb": "GB",
|
"gb": "GB",
|
||||||
"perGb": "/GB"
|
"perGb": "/GB"
|
||||||
@@ -301,7 +303,7 @@
|
|||||||
"additionalOptions": {
|
"additionalOptions": {
|
||||||
"title": "附加选项",
|
"title": "附加选项",
|
||||||
"buyDevices": "购买更多设备",
|
"buyDevices": "购买更多设备",
|
||||||
"currentDeviceLimit": "当前限制:{{count}} 台设备",
|
"currentDeviceLimit_other": "当前限制:{{count}} 台设备",
|
||||||
"maxDevices": "最多 {{count}} 台",
|
"maxDevices": "最多 {{count}} 台",
|
||||||
"devicesUnavailable": "设备购买不可用",
|
"devicesUnavailable": "设备购买不可用",
|
||||||
"devicesUnit": "台设备",
|
"devicesUnit": "台设备",
|
||||||
@@ -565,6 +567,7 @@
|
|||||||
"wheel": {
|
"wheel": {
|
||||||
"title": "幸运转盘",
|
"title": "幸运转盘",
|
||||||
"disabled": "幸运转盘暂时不可用",
|
"disabled": "幸运转盘暂时不可用",
|
||||||
|
"prizes": "奖品",
|
||||||
"spinsRemaining": "今日剩余次数",
|
"spinsRemaining": "今日剩余次数",
|
||||||
"choosePayment": "选择支付方式",
|
"choosePayment": "选择支付方式",
|
||||||
"stars": "Stars",
|
"stars": "Stars",
|
||||||
@@ -625,6 +628,7 @@
|
|||||||
"payments": "支付",
|
"payments": "支付",
|
||||||
"promoOffers": "促销优惠",
|
"promoOffers": "促销优惠",
|
||||||
"promocodes": "促销码",
|
"promocodes": "促销码",
|
||||||
|
"promoGroups": "折扣组",
|
||||||
"remnawave": "RemnaWave",
|
"remnawave": "RemnaWave",
|
||||||
"users": "用户"
|
"users": "用户"
|
||||||
},
|
},
|
||||||
@@ -646,6 +650,7 @@
|
|||||||
"paymentsDesc": "支付验证",
|
"paymentsDesc": "支付验证",
|
||||||
"promoOffersDesc": "个性化折扣",
|
"promoOffersDesc": "个性化折扣",
|
||||||
"promocodesDesc": "管理促销码",
|
"promocodesDesc": "管理促销码",
|
||||||
|
"promoGroupsDesc": "用户折扣组",
|
||||||
"remnawaveDesc": "面板管理和统计",
|
"remnawaveDesc": "面板管理和统计",
|
||||||
"usersDesc": "管理机器人用户"
|
"usersDesc": "管理机器人用户"
|
||||||
},
|
},
|
||||||
@@ -705,7 +710,9 @@
|
|||||||
"resetted": "模板已恢复默认",
|
"resetted": "模板已恢复默认",
|
||||||
"testSent": "测试邮件已发送",
|
"testSent": "测试邮件已发送",
|
||||||
"variables": "可用变量",
|
"variables": "可用变量",
|
||||||
"clickToCopy": "点击复制"
|
"clickToCopy": "点击复制",
|
||||||
|
"previewDesktopOnly": "预览仅在桌面网页版可用",
|
||||||
|
"previewNotAvailable": "预览不可用"
|
||||||
},
|
},
|
||||||
"payments": {
|
"payments": {
|
||||||
"title": "支付验证",
|
"title": "支付验证",
|
||||||
@@ -723,6 +730,7 @@
|
|||||||
},
|
},
|
||||||
"wheel": {
|
"wheel": {
|
||||||
"title": "幸运转盘设置",
|
"title": "幸运转盘设置",
|
||||||
|
"preview": "预览",
|
||||||
"enabled": "已启用",
|
"enabled": "已启用",
|
||||||
"disabled": "已禁用",
|
"disabled": "已禁用",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
@@ -738,13 +746,20 @@
|
|||||||
"rtpPercent": "RTP (返还率) %",
|
"rtpPercent": "RTP (返还率) %",
|
||||||
"dailyLimit": "每日限制 (0 = 无限)",
|
"dailyLimit": "每日限制 (0 = 无限)",
|
||||||
"minSubDays": "天数支付最低订阅天数",
|
"minSubDays": "天数支付最低订阅天数",
|
||||||
"promoPrefix": "优惠码前缀"
|
"promoPrefix": "优惠码前缀",
|
||||||
|
"spinCost": "旋转费用",
|
||||||
|
"limitsAndRtp": "限制和RTP",
|
||||||
|
"promocodes": "促销码"
|
||||||
},
|
},
|
||||||
"prizes": {
|
"prizes": {
|
||||||
"addPrize": "添加奖品",
|
"addPrize": "添加奖品",
|
||||||
"editPrize": "编辑奖品",
|
"editPrize": "编辑奖品",
|
||||||
"noPrizes": "未配置奖品。添加奖品以启用转盘。",
|
"noPrizes": "未配置奖品。添加奖品以启用转盘。",
|
||||||
"deletePrize": "删除此奖品?",
|
"deletePrize": "删除此奖品?",
|
||||||
|
"dragToReorder": "拖动排序",
|
||||||
|
"unsavedOrder": "有未保存的顺序更改",
|
||||||
|
"unsavedOrderHint": "保存或放弃更改",
|
||||||
|
"confirmDelete": "确定要删除这个奖品吗?",
|
||||||
"types": {
|
"types": {
|
||||||
"subscription_days": "订阅天数",
|
"subscription_days": "订阅天数",
|
||||||
"balance_bonus": "余额奖励",
|
"balance_bonus": "余额奖励",
|
||||||
@@ -805,6 +820,9 @@
|
|||||||
"filter": "筛选",
|
"filter": "筛选",
|
||||||
"message": "消息",
|
"message": "消息",
|
||||||
"unknownAdmin": "未知",
|
"unknownAdmin": "未知",
|
||||||
|
"detail": "群发详情",
|
||||||
|
"createdBy": "创建者",
|
||||||
|
"notFound": "未找到群发",
|
||||||
"prev": "上一页",
|
"prev": "上一页",
|
||||||
"next": "下一页",
|
"next": "下一页",
|
||||||
"status": {
|
"status": {
|
||||||
@@ -971,6 +989,8 @@
|
|||||||
"replyPlaceholder": "输入您的回复...",
|
"replyPlaceholder": "输入您的回复...",
|
||||||
"sendReply": "发送回复",
|
"sendReply": "发送回复",
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
|
"settingsSubtitle": "配置工单系统和SLA",
|
||||||
|
"settingsLoadError": "加载设置失败",
|
||||||
"supportMode": "支持模式",
|
"supportMode": "支持模式",
|
||||||
"supportModeDesc": "选择用户如何联系支持",
|
"supportModeDesc": "选择用户如何联系支持",
|
||||||
"modeBoth": "工单和联系方式",
|
"modeBoth": "工单和联系方式",
|
||||||
@@ -1023,6 +1043,8 @@
|
|||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"confirmDelete": "删除套餐?",
|
"confirmDelete": "删除套餐?",
|
||||||
"confirmDeleteText": "此操作不可撤销。套餐将被永久删除。",
|
"confirmDeleteText": "此操作不可撤销。套餐将被永久删除。",
|
||||||
|
"confirmDeleteWithSubscriptions": "此套餐有 {{count}} 个活跃订阅。删除后,用户续订时需要选择新套餐。",
|
||||||
|
"deleteSuccess": "套餐删除成功",
|
||||||
"days": "天",
|
"days": "天",
|
||||||
"days_other": "{{count}} 天",
|
"days_other": "{{count}} 天",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
@@ -1034,6 +1056,7 @@
|
|||||||
"serversHint": "选择此套餐可用的服务器",
|
"serversHint": "选择此套餐可用的服务器",
|
||||||
"noServers": "暂无可用服务器",
|
"noServers": "暂无可用服务器",
|
||||||
"selectType": "选择套餐类型",
|
"selectType": "选择套餐类型",
|
||||||
|
"selectTypeDesc": "为新套餐选择计费方式",
|
||||||
"periodTariff": "周期套餐",
|
"periodTariff": "周期套餐",
|
||||||
"periodTariffDesc": "按周期付款(7、30、90天等)。灵活的周期和价格。",
|
"periodTariffDesc": "按周期付款(7、30、90天等)。灵活的周期和价格。",
|
||||||
"dailyTariff": "日套餐",
|
"dailyTariff": "日套餐",
|
||||||
@@ -1050,6 +1073,7 @@
|
|||||||
"nameLabel": "套餐名称",
|
"nameLabel": "套餐名称",
|
||||||
"nameExamplePeriod": "例如:标准",
|
"nameExamplePeriod": "例如:标准",
|
||||||
"nameExampleDaily": "例如:日套餐",
|
"nameExampleDaily": "例如:日套餐",
|
||||||
|
"nameHint": "2到50个字符",
|
||||||
"descriptionLabel": "描述",
|
"descriptionLabel": "描述",
|
||||||
"trafficLimitLabel": "流量限制",
|
"trafficLimitLabel": "流量限制",
|
||||||
"gbUnit": "GB",
|
"gbUnit": "GB",
|
||||||
@@ -1057,6 +1081,7 @@
|
|||||||
"deviceLimitLabel": "套餐中的设备数",
|
"deviceLimitLabel": "套餐中的设备数",
|
||||||
"tierLevelLabel": "套餐等级",
|
"tierLevelLabel": "套餐等级",
|
||||||
"tierLevelHint": "影响套餐之间的升级可用性",
|
"tierLevelHint": "影响套餐之间的升级可用性",
|
||||||
|
"fieldRequired": "必填字段",
|
||||||
"periodsTabHint": "为套餐添加周期和价格。用户将能够从添加的周期中选择。",
|
"periodsTabHint": "为套餐添加周期和价格。用户将能够从添加的周期中选择。",
|
||||||
"addPeriodTitle": "添加周期",
|
"addPeriodTitle": "添加周期",
|
||||||
"daysLabel": "天",
|
"daysLabel": "天",
|
||||||
@@ -1064,8 +1089,12 @@
|
|||||||
"addButton": "添加",
|
"addButton": "添加",
|
||||||
"noPeriodsHint": "没有添加的周期。请至少添加一个周期。",
|
"noPeriodsHint": "没有添加的周期。请至少添加一个周期。",
|
||||||
"daysShort": "天",
|
"daysShort": "天",
|
||||||
|
"serversTitle": "服务器",
|
||||||
"serversTabHint": "选择此套餐上可用的服务器。",
|
"serversTabHint": "选择此套餐上可用的服务器。",
|
||||||
"noServersAvailable": "没有可用的服务器",
|
"noServersAvailable": "没有可用的服务器",
|
||||||
|
"promoGroupsTitle": "促销组",
|
||||||
|
"promoGroupsHint": "选择可以访问此套餐的促销组。如果未选择任何组,则所有人都可以访问。",
|
||||||
|
"noPromoGroups": "没有促销组",
|
||||||
"extraDeviceTitle": "额外设备",
|
"extraDeviceTitle": "额外设备",
|
||||||
"devicePriceLabel": "设备价格(30天):",
|
"devicePriceLabel": "设备价格(30天):",
|
||||||
"devicePriceHint": "0 = 无法购买设备",
|
"devicePriceHint": "0 = 无法购买设备",
|
||||||
@@ -1075,6 +1104,8 @@
|
|||||||
"trafficMaxLimitLabel": "最大限制:",
|
"trafficMaxLimitLabel": "最大限制:",
|
||||||
"trafficLimitHint2": "(0 = 无限制)",
|
"trafficLimitHint2": "(0 = 无限制)",
|
||||||
"trafficPackagesLabel": "流量包:",
|
"trafficPackagesLabel": "流量包:",
|
||||||
|
"addPackageTitle": "添加流量包",
|
||||||
|
"noPackagesHint": "没有添加流量包。请至少添加一个流量包。",
|
||||||
"gbPackageUnit": "GB:",
|
"gbPackageUnit": "GB:",
|
||||||
"customDaysTitle": "自定义天数",
|
"customDaysTitle": "自定义天数",
|
||||||
"customDaysDesc": "用户自行选择订阅期限",
|
"customDaysDesc": "用户自行选择订阅期限",
|
||||||
@@ -1105,7 +1136,24 @@
|
|||||||
"periodType": "周期套餐",
|
"periodType": "周期套餐",
|
||||||
"dailyPriceLabel": "每天价格",
|
"dailyPriceLabel": "每天价格",
|
||||||
"currencyPerDay": "₽/天",
|
"currencyPerDay": "₽/天",
|
||||||
"dailyDeductionDesc": "每天从用户余额中扣除"
|
"dailyDeductionDesc": "每天从用户余额中扣除",
|
||||||
|
"dailyPriceRequired": "请输入大于0的每日价格",
|
||||||
|
"cannotSave": "无法保存套餐:",
|
||||||
|
"statusTitle": "套餐状态",
|
||||||
|
"isActiveLabel": "已激活",
|
||||||
|
"isActiveHint": "未激活的套餐不会显示给用户",
|
||||||
|
"isTrialLabel": "试用套餐",
|
||||||
|
"isTrialHint": "可用于免费试用期",
|
||||||
|
"validation": {
|
||||||
|
"nameRequired": "请输入套餐名称",
|
||||||
|
"nameLength": "名称必须为2到50个字符",
|
||||||
|
"deviceLimitRequired": "请指定设备数量(至少1个)",
|
||||||
|
"tierLevelInvalid": "等级必须在1到10之间",
|
||||||
|
"fieldsRequired": "请填写必填字段(设备数、等级)",
|
||||||
|
"periodsRequired": "请添加至少一个期限",
|
||||||
|
"dailyPriceRequired": "请输入大于0的每日价格",
|
||||||
|
"trafficPackagesRequired": "请添加至少一个流量包(或关闭流量购买)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"servers": {
|
"servers": {
|
||||||
"title": "服务器管理",
|
"title": "服务器管理",
|
||||||
@@ -1136,7 +1184,10 @@
|
|||||||
"enable": "启用",
|
"enable": "启用",
|
||||||
"disable": "禁用",
|
"disable": "禁用",
|
||||||
"toggleTrial": "切换试用",
|
"toggleTrial": "切换试用",
|
||||||
"loadError": "加载服务器失败"
|
"loadError": "加载服务器失败",
|
||||||
|
"mainSettings": "基本设置",
|
||||||
|
"pricingAndLimits": "价格和限制",
|
||||||
|
"updateError": "保存更改失败"
|
||||||
},
|
},
|
||||||
"campaigns": {
|
"campaigns": {
|
||||||
"title": "广告活动",
|
"title": "广告活动",
|
||||||
@@ -1157,7 +1208,7 @@
|
|||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"inactive": "未激活",
|
"inactive": "未激活",
|
||||||
"registrations": "{{count}} 次注册",
|
"registrations_other": "{{count}} 次注册",
|
||||||
"revenue": "{{amount}} 收入",
|
"revenue": "{{amount}} 收入",
|
||||||
"conversion": "{{rate}}% 转化",
|
"conversion": "{{rate}}% 转化",
|
||||||
"statistics": "统计",
|
"statistics": "统计",
|
||||||
@@ -1195,13 +1246,16 @@
|
|||||||
"saving": "保存中..."
|
"saving": "保存中..."
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
|
"title": "活动统计",
|
||||||
|
"loadError": "加载统计失败",
|
||||||
|
"detailedStats": "详细统计",
|
||||||
"registrations": "注册数",
|
"registrations": "注册数",
|
||||||
"revenue": "收入",
|
"revenue": "收入",
|
||||||
"paidUsers": "已付费",
|
"paidUsers": "已付费",
|
||||||
"conversion": "转化率",
|
"conversion": "转化率",
|
||||||
"bonusesIssued": "已发放奖励",
|
"bonusesIssued": "已发放奖励",
|
||||||
"subscriptionsIssued": "{{count}} 个订阅",
|
"subscriptionsIssued_other": "{{count}} 个订阅",
|
||||||
"tariffsIssued": "{{count}} 个套餐",
|
"tariffsIssued_other": "{{count}} 个套餐",
|
||||||
"avgRevenuePerUser": "每用户平均收入",
|
"avgRevenuePerUser": "每用户平均收入",
|
||||||
"avgFirstPayment": "平均首次付款",
|
"avgFirstPayment": "平均首次付款",
|
||||||
"trialSubscriptions": "试用订阅",
|
"trialSubscriptions": "试用订阅",
|
||||||
@@ -1212,7 +1266,10 @@
|
|||||||
"inactive": "未激活",
|
"inactive": "未激活",
|
||||||
"copy": "复制",
|
"copy": "复制",
|
||||||
"copied": "已复制!",
|
"copied": "已复制!",
|
||||||
"users": "用户"
|
"users": "用户",
|
||||||
|
"noUsers": "没有注册用户",
|
||||||
|
"paid": "已付费",
|
||||||
|
"hasSub": "有订阅"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"title": "活动用户",
|
"title": "活动用户",
|
||||||
@@ -1234,7 +1291,9 @@
|
|||||||
"deleteText": "此操作不可撤销。有注册记录的活动无法删除。",
|
"deleteText": "此操作不可撤销。有注册记录的活动无法删除。",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"delete": "删除"
|
"delete": "删除"
|
||||||
}
|
},
|
||||||
|
"loadError": "加载活动失败",
|
||||||
|
"updateError": "保存更改失败"
|
||||||
},
|
},
|
||||||
"promocodes": {
|
"promocodes": {
|
||||||
"title": "促销码",
|
"title": "促销码",
|
||||||
@@ -1248,8 +1307,8 @@
|
|||||||
"until": "有效期至",
|
"until": "有效期至",
|
||||||
"discountForHours": "-{{percent}}% {{hours}}小时",
|
"discountForHours": "-{{percent}}% {{hours}}小时",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"promocodes": "促销码 ({{count}})",
|
"promocodes_other": "促销码 ({{count}})",
|
||||||
"groups": "折扣组 ({{count}})"
|
"groups_other": "折扣组 ({{count}})"
|
||||||
},
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"balance": "余额",
|
"balance": "余额",
|
||||||
@@ -1296,6 +1355,8 @@
|
|||||||
"save": "保存"
|
"save": "保存"
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
|
"title": "促销码统计",
|
||||||
|
"notFound": "未找到促销码",
|
||||||
"totalUses": "总使用次数",
|
"totalUses": "总使用次数",
|
||||||
"today": "今天",
|
"today": "今天",
|
||||||
"remaining": "剩余",
|
"remaining": "剩余",
|
||||||
@@ -1305,7 +1366,7 @@
|
|||||||
"daysLabel": "天数",
|
"daysLabel": "天数",
|
||||||
"discountLabel": "折扣",
|
"discountLabel": "折扣",
|
||||||
"validFor": "有效期",
|
"validFor": "有效期",
|
||||||
"hoursValue": "{{count}} 小时",
|
"hoursValue_other": "{{count}} 小时",
|
||||||
"limit": "限制",
|
"limit": "限制",
|
||||||
"status": "状态",
|
"status": "状态",
|
||||||
"active": "活跃",
|
"active": "活跃",
|
||||||
@@ -1346,7 +1407,7 @@
|
|||||||
"default": "默认",
|
"default": "默认",
|
||||||
"daysShort": "{{days}} 天",
|
"daysShort": "{{days}} 天",
|
||||||
"autoFrom": "自动 从 {{amount}} 卢布",
|
"autoFrom": "自动 从 {{amount}} 卢布",
|
||||||
"members": "{{count}} 名成员"
|
"members_other": "{{count}} 名成员"
|
||||||
},
|
},
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"deletePromocode": "删除促销码?",
|
"deletePromocode": "删除促销码?",
|
||||||
@@ -1354,6 +1415,62 @@
|
|||||||
"deletePromocodeText": "促销码将被永久删除。",
|
"deletePromocodeText": "促销码将被永久删除。",
|
||||||
"deleteGroupText": "该组将被删除。用户将失去该组的折扣。",
|
"deleteGroupText": "该组将被删除。用户将失去该组的折扣。",
|
||||||
"deleteButton": "删除"
|
"deleteButton": "删除"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"codeRequired": "请输入促销码",
|
||||||
|
"balanceRequired": "奖金金额必须大于0",
|
||||||
|
"daysRequired": "天数必须大于0",
|
||||||
|
"groupRequired": "请选择折扣组",
|
||||||
|
"discountPercentInvalid": "折扣百分比必须在1到100之间",
|
||||||
|
"discountHoursRequired": "请指定折扣有效期(小时)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"promoGroups": {
|
||||||
|
"title": "折扣组",
|
||||||
|
"subtitle": "管理用户折扣组",
|
||||||
|
"addGroup": "添加组",
|
||||||
|
"noGroups": "没有折扣组",
|
||||||
|
"default": "默认",
|
||||||
|
"servers": "服务器",
|
||||||
|
"traffic": "流量",
|
||||||
|
"devices": "设备",
|
||||||
|
"daysShort": "{{days}} 天",
|
||||||
|
"autoFrom": "自动 从 {{amount}} 卢布",
|
||||||
|
"members": "{{count}} 名成员",
|
||||||
|
"createTitle": "创建折扣组",
|
||||||
|
"editTitle": "编辑组",
|
||||||
|
"stats": {
|
||||||
|
"total": "组总数",
|
||||||
|
"members": "成员",
|
||||||
|
"autoAssign": "自动分配"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"edit": "编辑",
|
||||||
|
"delete": "删除"
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"title": "删除组?",
|
||||||
|
"text": "该组将被删除。用户将失去该组的折扣。",
|
||||||
|
"cancel": "取消",
|
||||||
|
"delete": "删除"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"name": "组名称",
|
||||||
|
"namePlaceholder": "VIP客户",
|
||||||
|
"categoryDiscounts": "分类折扣",
|
||||||
|
"periodDiscounts": "期间折扣",
|
||||||
|
"add": "添加",
|
||||||
|
"periodHint": "购买指定天数订阅时应用折扣",
|
||||||
|
"noPeriods": "没有期间折扣",
|
||||||
|
"daysPlaceholder": "天",
|
||||||
|
"arrow": "天 →",
|
||||||
|
"autoAssign": "消费满额自动分配",
|
||||||
|
"rub": "卢布",
|
||||||
|
"autoAssignHint": "0 = 不自动分配",
|
||||||
|
"applyToAddons": "应用于附加服务",
|
||||||
|
"cancel": "取消",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"save": "保存"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
@@ -1362,7 +1479,7 @@
|
|||||||
"search": "按ID、姓名、用户名搜索...",
|
"search": "按ID、姓名、用户名搜索...",
|
||||||
"searchEmail": "按邮箱搜索...",
|
"searchEmail": "按邮箱搜索...",
|
||||||
"noData": "未找到用户",
|
"noData": "未找到用户",
|
||||||
"purchaseCount": "{{count}} 次购买",
|
"purchaseCount_other": "{{count}} 次购买",
|
||||||
"noPurchases": "无购买记录",
|
"noPurchases": "无购买记录",
|
||||||
"stats": {
|
"stats": {
|
||||||
"total": "总计",
|
"total": "总计",
|
||||||
@@ -1473,7 +1590,7 @@
|
|||||||
"subtitle": "模板、群发和优惠日志",
|
"subtitle": "模板、群发和优惠日志",
|
||||||
"sendButton": "发送",
|
"sendButton": "发送",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"templates": "模板 ({{count}})",
|
"templates_other": "模板 ({{count}})",
|
||||||
"logs": "日志"
|
"logs": "日志"
|
||||||
},
|
},
|
||||||
"noData": {
|
"noData": {
|
||||||
@@ -1515,17 +1632,24 @@
|
|||||||
"segment": "分组",
|
"segment": "分组",
|
||||||
"user": "用户",
|
"user": "用户",
|
||||||
"userIdPlaceholder": "Telegram ID 或 User ID",
|
"userIdPlaceholder": "Telegram ID 或 User ID",
|
||||||
|
"searchUserPlaceholder": "按姓名、用户名或Telegram ID搜索...",
|
||||||
|
"noUsersFound": "未找到用户",
|
||||||
|
"hasSubscription": "订阅",
|
||||||
"preview": "预览",
|
"preview": "预览",
|
||||||
"sending": "发送中...",
|
"sending": "发送中...",
|
||||||
"sendButton": "发送"
|
"sendButton": "发送"
|
||||||
},
|
},
|
||||||
"result": {
|
"result": {
|
||||||
"sentTitle": "已发送!",
|
"sentTitle": "已发送!",
|
||||||
"offersCreated": "已创建优惠: {{count}}",
|
"offersCreated_other": "已创建优惠: {{count}}",
|
||||||
"notificationsSent": "已发送通知: {{count}}",
|
"notificationsSent_other": "已发送通知: {{count}}",
|
||||||
"notificationsFailed": "(失败: {{count}})",
|
"notificationsFailed": "(失败: {{count}})",
|
||||||
"sendError": "发送优惠失败"
|
"sendError": "发送优惠失败"
|
||||||
},
|
},
|
||||||
|
"notFound": "未找到模板",
|
||||||
|
"noActiveTemplates": "没有活跃的模板",
|
||||||
|
"backToList": "返回优惠列表",
|
||||||
|
"sendAnother": "再发送一个",
|
||||||
"actions": {
|
"actions": {
|
||||||
"created": "已创建",
|
"created": "已创建",
|
||||||
"claimed": "已领取",
|
"claimed": "已领取",
|
||||||
@@ -1596,7 +1720,18 @@
|
|||||||
"offline": "离线",
|
"offline": "离线",
|
||||||
"online": "在线",
|
"online": "在线",
|
||||||
"restart": "重启",
|
"restart": "重启",
|
||||||
"restartAll": "全部重启"
|
"restartAll": "全部重启",
|
||||||
|
"usersOnline": "在线",
|
||||||
|
"usersOnlineCount_other": "{{count}} 在线",
|
||||||
|
"trafficUsed": "已使用",
|
||||||
|
"uptimeLabel": "运行时间",
|
||||||
|
"stats": {
|
||||||
|
"total": "总计",
|
||||||
|
"online": "在线",
|
||||||
|
"offline": "离线",
|
||||||
|
"disabled": "已禁用",
|
||||||
|
"users": "用户"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"bandwidth": "实时带宽",
|
"bandwidth": "实时带宽",
|
||||||
@@ -1610,6 +1745,11 @@
|
|||||||
"total": "总计",
|
"total": "总计",
|
||||||
"totalUsers": "总用户数",
|
"totalUsers": "总用户数",
|
||||||
"traffic": "流量统计",
|
"traffic": "流量统计",
|
||||||
|
"traffic2days": "2天",
|
||||||
|
"traffic7days": "7天",
|
||||||
|
"traffic30days": "30天",
|
||||||
|
"trafficMonth": "本月",
|
||||||
|
"trafficYear": "本年",
|
||||||
"upload": "上传",
|
"upload": "上传",
|
||||||
"uptime": "运行时间",
|
"uptime": "运行时间",
|
||||||
"usersByStatus": "按状态分类的用户",
|
"usersByStatus": "按状态分类的用户",
|
||||||
@@ -1619,23 +1759,48 @@
|
|||||||
"noSquads": "未找到小队",
|
"noSquads": "未找到小队",
|
||||||
"notSynced": "未同步",
|
"notSynced": "未同步",
|
||||||
"syncServers": "同步服务器",
|
"syncServers": "同步服务器",
|
||||||
"synced": "已同步"
|
"synced": "已同步",
|
||||||
|
"members": "成员",
|
||||||
|
"membersCount_other": "{{count}} 成员",
|
||||||
|
"inbounds": "入站",
|
||||||
|
"inboundsCount_other": "{{count}} 入站",
|
||||||
|
"available": "可用",
|
||||||
|
"unavailable": "不可用",
|
||||||
|
"stats": {
|
||||||
|
"total": "总计",
|
||||||
|
"synced": "已同步",
|
||||||
|
"available": "可用",
|
||||||
|
"members": "成员"
|
||||||
|
},
|
||||||
|
"detail": "小队详情",
|
||||||
|
"loadError": "加载小队失败",
|
||||||
|
"info": "信息",
|
||||||
|
"originalName": "原始名称",
|
||||||
|
"countryCode": "国家",
|
||||||
|
"statsTitle": "统计",
|
||||||
|
"inboundsList": "入站列表",
|
||||||
|
"localSettings": "本地设置",
|
||||||
|
"trialEligible": "可试用",
|
||||||
|
"price": "价格",
|
||||||
|
"users": "用户"
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"autoSync": "自动同步",
|
"autoSync": "自动同步",
|
||||||
"cleanup": "清理",
|
"runNow": "运行",
|
||||||
"cleanupDesc": "删除无用户的孤立订阅",
|
"run": "运行",
|
||||||
"fromPanel": "从面板同步",
|
"running": "运行中...",
|
||||||
"fromPanelDesc": "从RemnaWave面板导入用户到机器人数据库",
|
"schedule": "计划",
|
||||||
"manual": "手动同步",
|
"status": "状态",
|
||||||
"runNow": "立即运行",
|
"lastRun": "上次运行",
|
||||||
"statuses": "同步状态",
|
"nextRun": "下次运行",
|
||||||
"statusesDesc": "与面板同步订阅状态",
|
"enabled": "已启用",
|
||||||
"subscriptions": "订阅",
|
"disabled": "已禁用",
|
||||||
"toPanel": "同步到面板",
|
"success": "成功",
|
||||||
"toPanelDesc": "从机器人数据库导出用户到RemnaWave面板",
|
"runAutoSyncNow": "立即运行自动同步",
|
||||||
"validate": "验证",
|
"fromPanel": "从面板",
|
||||||
"validateDesc": "检查并修复订阅不一致"
|
"fromPanelDesc": "从RemnaWave导入用户到机器人",
|
||||||
|
"toPanel": "到面板",
|
||||||
|
"toPanelDesc": "从机器人导出用户到RemnaWave"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1664,7 +1829,7 @@
|
|||||||
"enable": "启用",
|
"enable": "启用",
|
||||||
"disable": "禁用",
|
"disable": "禁用",
|
||||||
"hide": "隐藏 ({{count}})",
|
"hide": "隐藏 ({{count}})",
|
||||||
"showMore": "显示更多 {{count}} 个节点"
|
"showMore_other": "显示更多 {{count}} 个节点"
|
||||||
},
|
},
|
||||||
"revenue": {
|
"revenue": {
|
||||||
"title": "收入",
|
"title": "收入",
|
||||||
|
|||||||
19
src/main.tsx
19
src/main.tsx
@@ -1,17 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import App from './App';
|
import { AppWithNavigator } from './AppWithNavigator';
|
||||||
import { ThemeColorsProvider } from './providers/ThemeColorsProvider';
|
|
||||||
import { ToastProvider } from './components/Toast';
|
|
||||||
import { initLogoPreload } from './api/branding';
|
import { initLogoPreload } from './api/branding';
|
||||||
import { initTelegramWebApp } from './hooks/useTelegramWebApp';
|
import { initTelegramSDK } from './hooks/useTelegramSDK';
|
||||||
import './i18n';
|
import './i18n';
|
||||||
import './styles/globals.css';
|
import './styles/globals.css';
|
||||||
|
|
||||||
// Initialize Telegram WebApp (expand, disable swipes)
|
// Initialize Telegram SDK (init, viewport mount, CSS vars binding, swipe control)
|
||||||
initTelegramWebApp();
|
initTelegramSDK();
|
||||||
|
|
||||||
// Preload logo from cache immediately on page load
|
// Preload logo from cache immediately on page load
|
||||||
initLogoPreload();
|
initLogoPreload();
|
||||||
@@ -28,13 +25,7 @@ const queryClient = new QueryClient({
|
|||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<AppWithNavigator />
|
||||||
<ThemeColorsProvider>
|
|
||||||
<ToastProvider>
|
|
||||||
<App />
|
|
||||||
</ToastProvider>
|
|
||||||
</ThemeColorsProvider>
|
|
||||||
</BrowserRouter>
|
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import {
|
import {
|
||||||
adminAppsApi,
|
adminAppsApi,
|
||||||
AppDefinition,
|
AppDefinition,
|
||||||
@@ -13,8 +13,11 @@ import {
|
|||||||
RemnawaveConfig,
|
RemnawaveConfig,
|
||||||
importFromRemnawaveFormat as convertRemnawave,
|
importFromRemnawaveFormat as convertRemnawave,
|
||||||
} from '../api/adminApps';
|
} from '../api/adminApps';
|
||||||
|
import { useBackButton } from '../platform/hooks/useBackButton';
|
||||||
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
|
|
||||||
const BackIcon = () => (
|
const BackIcon = () => (
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5 text-dark-400"
|
className="h-5 w-5 text-dark-400"
|
||||||
@@ -87,7 +90,7 @@ const CopyIcon = () => (
|
|||||||
|
|
||||||
const StarIcon = ({ filled }: { filled: boolean }) => (
|
const StarIcon = ({ filled }: { filled: boolean }) => (
|
||||||
<svg
|
<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"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
@@ -582,7 +585,13 @@ function AppEditorModal({ app, platform, isNew, onSave, onClose }: AppEditorModa
|
|||||||
|
|
||||||
export default function AdminApps() {
|
export default function AdminApps() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { capabilities } = usePlatform();
|
||||||
|
|
||||||
|
// Use native Telegram back button in Mini App
|
||||||
|
useBackButton(() => navigate('/admin'));
|
||||||
|
|
||||||
const [selectedPlatform, setSelectedPlatform] = useState<string>('ios');
|
const [selectedPlatform, setSelectedPlatform] = useState<string>('ios');
|
||||||
const [editingApp, setEditingApp] = useState<{ app: AppDefinition; isNew: boolean } | null>(null);
|
const [editingApp, setEditingApp] = useState<{ app: AppDefinition; isNew: boolean } | null>(null);
|
||||||
const [copyTarget, setCopyTarget] = useState<{ appId: string; platform: string } | null>(null);
|
const [copyTarget, setCopyTarget] = useState<{ appId: string; platform: string } | null>(null);
|
||||||
@@ -816,12 +825,15 @@ export default function AdminApps() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link
|
{/* Show back button only on web, not in Telegram Mini App */}
|
||||||
to="/admin"
|
{!capabilities.hasBackButton && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/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"
|
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 />
|
<BackIcon />
|
||||||
</Link>
|
</button>
|
||||||
|
)}
|
||||||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('admin.apps.title')}</h1>
|
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('admin.apps.title')}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -509,7 +509,7 @@ export default function AdminBanSystem() {
|
|||||||
href="https://t.me/fringg"
|
href="https://t.me/fringg"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex w-full items-center justify-center gap-3 rounded-xl bg-gradient-to-r from-[#0088cc] to-[#0099dd] px-4 py-3 font-medium text-white transition-all duration-200 hover:scale-[1.02] hover:from-[#0077bb] hover:to-[#0088cc] hover:shadow-lg hover:shadow-[#0088cc]/20"
|
className="flex w-full items-center justify-center gap-2 rounded-lg bg-gradient-to-r from-[#0088cc] to-[#0099dd] px-4 py-2 text-sm font-medium text-white transition-all duration-200 hover:from-[#0077bb] hover:to-[#0088cc] hover:shadow-lg hover:shadow-[#0088cc]/20"
|
||||||
>
|
>
|
||||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
<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" />
|
<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" />
|
||||||
@@ -520,7 +520,7 @@ export default function AdminBanSystem() {
|
|||||||
{/* Back Button */}
|
{/* Back Button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => window.history.back()}
|
onClick={() => window.history.back()}
|
||||||
className="flex w-full items-center justify-center gap-2 rounded-xl border border-dark-600 bg-dark-700 px-4 py-3 font-medium text-dark-200 transition-all duration-200 hover:border-dark-500 hover:bg-dark-600 hover:text-dark-100"
|
className="flex w-full items-center justify-center gap-2 rounded-lg border border-dark-600 bg-dark-700 px-4 py-2 text-sm font-medium text-dark-200 transition-all duration-200 hover:border-dark-500 hover:bg-dark-600 hover:text-dark-100"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5"
|
className="h-5 w-5"
|
||||||
|
|||||||
664
src/pages/AdminBroadcastCreate.tsx
Normal file
664
src/pages/AdminBroadcastCreate.tsx
Normal file
@@ -0,0 +1,664 @@
|
|||||||
|
import { useState, useRef, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
adminBroadcastsApi,
|
||||||
|
BroadcastFilter,
|
||||||
|
TariffFilter,
|
||||||
|
BroadcastChannel,
|
||||||
|
CombinedBroadcastCreateRequest,
|
||||||
|
} from '../api/adminBroadcasts';
|
||||||
|
import { AdminBackButton } from '../components/admin';
|
||||||
|
import { useBackButton } from '../platform/hooks/useBackButton';
|
||||||
|
|
||||||
|
// Icons
|
||||||
|
const BroadcastIcon = () => (
|
||||||
|
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"
|
||||||
|
/>
|
||||||
|
</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 RefreshIcon = () => (
|
||||||
|
<svg className="h-4 w-4" 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 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 UsersIcon = () => (
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<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 ChevronDownIcon = () => (
|
||||||
|
<svg className="h-4 w-4" 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 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>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter labels
|
||||||
|
const FILTER_GROUP_LABEL_KEYS: Record<string, string> = {
|
||||||
|
basic: 'admin.broadcasts.filterGroups.basic',
|
||||||
|
subscription: 'admin.broadcasts.filterGroups.subscription',
|
||||||
|
traffic: 'admin.broadcasts.filterGroups.traffic',
|
||||||
|
registration: 'admin.broadcasts.filterGroups.registration',
|
||||||
|
activity: 'admin.broadcasts.filterGroups.activity',
|
||||||
|
source: 'admin.broadcasts.filterGroups.source',
|
||||||
|
tariff: 'admin.broadcasts.filterGroups.tariff',
|
||||||
|
email: 'admin.broadcasts.filterGroups.email',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminBroadcastCreate() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useBackButton(() => navigate('/admin/broadcasts'));
|
||||||
|
|
||||||
|
// Channel selection
|
||||||
|
const [channel, setChannel] = useState<BroadcastChannel>('telegram');
|
||||||
|
|
||||||
|
// Common state
|
||||||
|
const [target, setTarget] = useState('');
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
|
||||||
|
// Telegram-specific state
|
||||||
|
const [messageText, setMessageText] = useState('');
|
||||||
|
const [selectedButtons, setSelectedButtons] = useState<string[]>(['home']);
|
||||||
|
const [mediaFile, setMediaFile] = useState<File | null>(null);
|
||||||
|
const [mediaType, setMediaType] = useState<'photo' | 'video' | 'document'>('photo');
|
||||||
|
const [mediaPreview, setMediaPreview] = useState<string | null>(null);
|
||||||
|
const [uploadedFileId, setUploadedFileId] = useState<string | null>(null);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
|
// Email-specific state
|
||||||
|
const [emailSubject, setEmailSubject] = useState('');
|
||||||
|
const [emailContent, setEmailContent] = useState('');
|
||||||
|
|
||||||
|
// Fetch Telegram filters
|
||||||
|
const { data: filtersData, isLoading: filtersLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'broadcasts', 'filters'],
|
||||||
|
queryFn: adminBroadcastsApi.getFilters,
|
||||||
|
enabled: channel === 'telegram',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch Email filters
|
||||||
|
const { data: emailFiltersData, isLoading: emailFiltersLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'broadcasts', 'email-filters'],
|
||||||
|
queryFn: adminBroadcastsApi.getEmailFilters,
|
||||||
|
enabled: channel === 'email',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch buttons
|
||||||
|
const { data: buttonsData } = useQuery({
|
||||||
|
queryKey: ['admin', 'broadcasts', 'buttons'],
|
||||||
|
queryFn: adminBroadcastsApi.getButtons,
|
||||||
|
enabled: channel === 'telegram',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Preview mutations
|
||||||
|
const previewMutation = useMutation({
|
||||||
|
mutationFn: adminBroadcastsApi.preview,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emailPreviewMutation = useMutation({
|
||||||
|
mutationFn: adminBroadcastsApi.previewEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create mutation
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: adminBroadcastsApi.createCombined,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] });
|
||||||
|
navigate(`/admin/broadcasts/${data.id}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group Telegram filters
|
||||||
|
const groupedFilters = useMemo(() => {
|
||||||
|
if (!filtersData) return {};
|
||||||
|
const groups: Record<string, (BroadcastFilter | TariffFilter)[]> = {};
|
||||||
|
|
||||||
|
filtersData.filters.forEach((f) => {
|
||||||
|
const group = f.group || 'basic';
|
||||||
|
if (!groups[group]) groups[group] = [];
|
||||||
|
groups[group].push(f);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filtersData.tariff_filters.length > 0) {
|
||||||
|
groups['tariff'] = filtersData.tariff_filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
filtersData.custom_filters.forEach((f) => {
|
||||||
|
const group = f.group || 'custom';
|
||||||
|
if (!groups[group]) groups[group] = [];
|
||||||
|
groups[group].push(f);
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}, [filtersData]);
|
||||||
|
|
||||||
|
// Group Email filters
|
||||||
|
const groupedEmailFilters = useMemo(() => {
|
||||||
|
if (!emailFiltersData) return {};
|
||||||
|
const groups: Record<string, BroadcastFilter[]> = {};
|
||||||
|
|
||||||
|
emailFiltersData.filters.forEach((f) => {
|
||||||
|
const group = f.group || 'email';
|
||||||
|
if (!groups[group]) groups[group] = [];
|
||||||
|
groups[group].push(f);
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}, [emailFiltersData]);
|
||||||
|
|
||||||
|
// Current filters based on channel
|
||||||
|
const currentFilters = channel === 'telegram' ? groupedFilters : groupedEmailFilters;
|
||||||
|
const isFiltersLoading = channel === 'telegram' ? filtersLoading : emailFiltersLoading;
|
||||||
|
|
||||||
|
// Selected filter info
|
||||||
|
const selectedFilter = useMemo(() => {
|
||||||
|
if (!target) return null;
|
||||||
|
|
||||||
|
if (channel === 'telegram' && filtersData) {
|
||||||
|
const all = [
|
||||||
|
...filtersData.filters,
|
||||||
|
...filtersData.tariff_filters,
|
||||||
|
...filtersData.custom_filters,
|
||||||
|
];
|
||||||
|
return all.find((f) => f.key === target);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel === 'email' && emailFiltersData) {
|
||||||
|
return emailFiltersData.filters.find((f) => f.key === target);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [target, channel, filtersData, emailFiltersData]);
|
||||||
|
|
||||||
|
// Handle channel change
|
||||||
|
const handleChannelChange = (newChannel: BroadcastChannel) => {
|
||||||
|
setChannel(newChannel);
|
||||||
|
setTarget('');
|
||||||
|
previewMutation.reset();
|
||||||
|
emailPreviewMutation.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle filter selection
|
||||||
|
const handleFilterSelect = (filterKey: string) => {
|
||||||
|
setTarget(filterKey);
|
||||||
|
setShowFilters(false);
|
||||||
|
|
||||||
|
if (channel === 'telegram') {
|
||||||
|
previewMutation.mutate(filterKey);
|
||||||
|
} else {
|
||||||
|
emailPreviewMutation.mutate(filterKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle file selection
|
||||||
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setMediaFile(file);
|
||||||
|
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
setMediaType('photo');
|
||||||
|
setMediaPreview(URL.createObjectURL(file));
|
||||||
|
} else if (file.type.startsWith('video/')) {
|
||||||
|
setMediaType('video');
|
||||||
|
setMediaPreview(null);
|
||||||
|
} else {
|
||||||
|
setMediaType('document');
|
||||||
|
setMediaPreview(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
try {
|
||||||
|
const result = await adminBroadcastsApi.uploadMedia(file, mediaType);
|
||||||
|
setUploadedFileId(result.file_id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Upload failed:', err);
|
||||||
|
setMediaFile(null);
|
||||||
|
setMediaPreview(null);
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove media
|
||||||
|
const handleRemoveMedia = () => {
|
||||||
|
setMediaFile(null);
|
||||||
|
setMediaPreview(null);
|
||||||
|
setUploadedFileId(null);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle button
|
||||||
|
const toggleButton = (key: string) => {
|
||||||
|
setSelectedButtons((prev) =>
|
||||||
|
prev.includes(key) ? prev.filter((b) => b !== key) : [...prev, key],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate form
|
||||||
|
const isValid = useMemo(() => {
|
||||||
|
if (!target) return false;
|
||||||
|
|
||||||
|
if (channel === 'telegram') {
|
||||||
|
return messageText.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel === 'email') {
|
||||||
|
return emailSubject.trim().length > 0 && emailContent.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}, [target, channel, messageText, emailSubject, emailContent]);
|
||||||
|
|
||||||
|
// Submit
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
const data: CombinedBroadcastCreateRequest = {
|
||||||
|
channel,
|
||||||
|
target,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (channel === 'telegram') {
|
||||||
|
data.message_text = messageText;
|
||||||
|
data.selected_buttons = selectedButtons;
|
||||||
|
|
||||||
|
if (uploadedFileId) {
|
||||||
|
data.media = {
|
||||||
|
type: mediaType,
|
||||||
|
file_id: uploadedFileId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel === 'email') {
|
||||||
|
data.email_subject = emailSubject;
|
||||||
|
data.email_html_content = emailContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
createMutation.mutate(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const recipientsCount =
|
||||||
|
channel === 'telegram'
|
||||||
|
? (previewMutation.data?.count ?? selectedFilter?.count ?? null)
|
||||||
|
: (emailPreviewMutation.data?.count ?? selectedFilter?.count ?? null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<AdminBackButton />
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-lg bg-accent-500/20 p-2 text-accent-400">
|
||||||
|
<BroadcastIcon />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-dark-100">{t('admin.broadcasts.create')}</h1>
|
||||||
|
<p className="text-sm text-dark-400">{t('admin.broadcasts.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Channel selection */}
|
||||||
|
<div className="card">
|
||||||
|
<label className="mb-3 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.broadcasts.selectChannel')}
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleChannelChange('telegram')}
|
||||||
|
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-4 transition-all ${
|
||||||
|
channel === 'telegram'
|
||||||
|
? 'border-accent-500 bg-accent-500/10 text-accent-400'
|
||||||
|
: 'border-dark-700 bg-dark-800 text-dark-300 hover:border-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<TelegramIcon />
|
||||||
|
<span className="font-medium">{t('admin.broadcasts.channel.telegram')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleChannelChange('email')}
|
||||||
|
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-4 transition-all ${
|
||||||
|
channel === 'email'
|
||||||
|
? 'border-accent-500 bg-accent-500/10 text-accent-400'
|
||||||
|
: 'border-dark-700 bg-dark-800 text-dark-300 hover:border-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<EmailIcon />
|
||||||
|
<span className="font-medium">{t('admin.broadcasts.channel.email')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="card space-y-6">
|
||||||
|
{/* Section title */}
|
||||||
|
<h2 className="text-lg font-semibold text-dark-100">
|
||||||
|
{channel === 'telegram'
|
||||||
|
? t('admin.broadcasts.telegramSection')
|
||||||
|
: t('admin.broadcasts.emailSection')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Filter selection */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
|
{channel === 'telegram'
|
||||||
|
? t('admin.broadcasts.selectFilter')
|
||||||
|
: t('admin.broadcasts.selectEmailFilter')}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
|
className="flex w-full items-center justify-between rounded-lg border border-dark-700 bg-dark-800 p-3 text-left transition-colors hover:border-dark-600"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UsersIcon />
|
||||||
|
<span className={selectedFilter ? 'text-dark-100' : 'text-dark-400'}>
|
||||||
|
{selectedFilter
|
||||||
|
? selectedFilter.label
|
||||||
|
: channel === 'telegram'
|
||||||
|
? t('admin.broadcasts.selectFilterPlaceholder')
|
||||||
|
: t('admin.broadcasts.selectEmailFilterPlaceholder')}
|
||||||
|
</span>
|
||||||
|
{recipientsCount !== null && (
|
||||||
|
<span className="rounded-full bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
|
||||||
|
{recipientsCount} {t('admin.broadcasts.recipients')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ChevronDownIcon />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showFilters && (
|
||||||
|
<div className="absolute left-0 right-0 top-full z-10 mt-1 max-h-64 overflow-y-auto rounded-lg border border-dark-700 bg-dark-800 shadow-xl">
|
||||||
|
{isFiltersLoading ? (
|
||||||
|
<div className="p-4 text-center text-dark-400">{t('common.loading')}</div>
|
||||||
|
) : (
|
||||||
|
Object.entries(currentFilters).map(([group, filters]) => (
|
||||||
|
<div key={group}>
|
||||||
|
<div className="sticky top-0 bg-dark-900 px-3 py-2 text-xs font-medium text-dark-400">
|
||||||
|
{FILTER_GROUP_LABEL_KEYS[group] ? t(FILTER_GROUP_LABEL_KEYS[group]) : group}
|
||||||
|
</div>
|
||||||
|
{filters.map((filter) => (
|
||||||
|
<button
|
||||||
|
key={filter.key}
|
||||||
|
onClick={() => handleFilterSelect(filter.key)}
|
||||||
|
className={`flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-dark-700 ${
|
||||||
|
target === filter.key ? 'bg-accent-500/20' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-dark-100">{filter.label}</span>
|
||||||
|
{filter.count !== null && filter.count !== undefined && (
|
||||||
|
<span className="text-xs text-dark-400">{filter.count}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Telegram-specific fields */}
|
||||||
|
{channel === 'telegram' && (
|
||||||
|
<>
|
||||||
|
{/* Message text */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.broadcasts.messageText')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={messageText}
|
||||||
|
onChange={(e) => setMessageText(e.target.value)}
|
||||||
|
placeholder={t('admin.broadcasts.messageTextPlaceholder')}
|
||||||
|
rows={6}
|
||||||
|
maxLength={4000}
|
||||||
|
className="input min-h-[150px] resize-y"
|
||||||
|
/>
|
||||||
|
<div className="mt-1 text-right text-xs text-dark-400">{messageText.length}/4000</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Media upload */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.broadcasts.media')}
|
||||||
|
</label>
|
||||||
|
{mediaFile ? (
|
||||||
|
<div className="rounded-lg border border-dark-700 bg-dark-800 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{mediaType === 'photo' && <PhotoIcon />}
|
||||||
|
{mediaType === 'video' && <VideoIcon />}
|
||||||
|
{mediaType === 'document' && <DocumentIcon />}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-dark-100">{mediaFile.name}</p>
|
||||||
|
<p className="text-xs text-dark-400">
|
||||||
|
{(mediaFile.size / 1024 / 1024).toFixed(2)} MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleRemoveMedia}
|
||||||
|
className="rounded-lg p-2 text-dark-400 hover:bg-dark-700 hover:text-error-400"
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{mediaPreview && (
|
||||||
|
<img
|
||||||
|
src={mediaPreview}
|
||||||
|
alt="Preview"
|
||||||
|
className="mt-3 max-h-48 rounded-lg object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isUploading && (
|
||||||
|
<div className="mt-2 flex items-center gap-2 text-sm text-accent-400">
|
||||||
|
<RefreshIcon />
|
||||||
|
{t('admin.broadcasts.uploading')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*,video/*,application/*"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-dark-600 bg-dark-800/50 p-6 text-dark-400 transition-colors hover:border-dark-500 hover:bg-dark-800 hover:text-dark-300"
|
||||||
|
>
|
||||||
|
<PhotoIcon />
|
||||||
|
<span>{t('admin.broadcasts.addMedia')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Buttons selection */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.broadcasts.buttons')}
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{buttonsData?.buttons.map((button) => (
|
||||||
|
<button
|
||||||
|
key={button.key}
|
||||||
|
onClick={() => toggleButton(button.key)}
|
||||||
|
className={`rounded-lg px-3 py-2 text-sm transition-colors ${
|
||||||
|
selectedButtons.includes(button.key)
|
||||||
|
? 'bg-accent-500 text-white'
|
||||||
|
: 'border border-dark-700 bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{button.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Email-specific fields */}
|
||||||
|
{channel === 'email' && (
|
||||||
|
<>
|
||||||
|
{/* Email subject */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.broadcasts.emailSubject')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={emailSubject}
|
||||||
|
onChange={(e) => setEmailSubject(e.target.value)}
|
||||||
|
placeholder={t('admin.broadcasts.emailSubjectPlaceholder')}
|
||||||
|
className="input"
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email content */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.broadcasts.emailContent')}
|
||||||
|
</label>
|
||||||
|
<p className="mb-2 text-xs text-dark-400">{t('admin.broadcasts.emailContentHint')}</p>
|
||||||
|
<textarea
|
||||||
|
value={emailContent}
|
||||||
|
onChange={(e) => setEmailContent(e.target.value)}
|
||||||
|
placeholder={t('admin.broadcasts.emailContentPlaceholder')}
|
||||||
|
rows={10}
|
||||||
|
className="input min-h-[200px] resize-y font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email variables hint */}
|
||||||
|
<div className="rounded-lg border border-dark-700 bg-dark-800/50 p-4">
|
||||||
|
<p className="mb-2 text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.broadcasts.emailVariables')}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{['{{user_name}}', '{{email}}', '{{user_id}}'].map((variable) => (
|
||||||
|
<code
|
||||||
|
key={variable}
|
||||||
|
className="rounded bg-dark-700 px-2 py-1 text-xs text-accent-400"
|
||||||
|
>
|
||||||
|
{variable}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="card flex items-center justify-between">
|
||||||
|
<div className="text-sm text-dark-400">
|
||||||
|
{recipientsCount !== null && (
|
||||||
|
<span>
|
||||||
|
{t('admin.broadcasts.willBeSent')}:{' '}
|
||||||
|
<strong className="text-accent-400">{recipientsCount}</strong>{' '}
|
||||||
|
{t('admin.broadcasts.users')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={() => navigate('/admin/broadcasts')} className="btn-secondary">
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!isValid || createMutation.isPending || isUploading}
|
||||||
|
className="btn-primary flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? <RefreshIcon /> : <BroadcastIcon />}
|
||||||
|
{t('admin.broadcasts.send')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
366
src/pages/AdminBroadcastDetail.tsx
Normal file
366
src/pages/AdminBroadcastDetail.tsx
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
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';
|
||||||
|
import { useBackButton } from '../platform/hooks/useBackButton';
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
useBackButton(() => navigate('/admin/broadcasts'));
|
||||||
|
|
||||||
|
// 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-lg border border-error-500/30 bg-error-500/20 px-4 py-2 text-sm 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
451
src/pages/AdminCampaignCreate.tsx
Normal file
451
src/pages/AdminCampaignCreate.tsx
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
campaignsApi,
|
||||||
|
CampaignCreateRequest,
|
||||||
|
CampaignBonusType,
|
||||||
|
ServerSquadInfo,
|
||||||
|
TariffListItem,
|
||||||
|
} from '../api/campaigns';
|
||||||
|
import { AdminBackButton } from '../components/admin';
|
||||||
|
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
|
||||||
|
import { useBackButton } from '../platform/hooks/useBackButton';
|
||||||
|
|
||||||
|
// Icons
|
||||||
|
const CampaignIcon = () => (
|
||||||
|
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CheckIcon = () => (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RefreshIcon = () => (
|
||||||
|
<svg className="h-4 w-4" 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>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bonus type config
|
||||||
|
const bonusTypeConfig: Record<
|
||||||
|
CampaignBonusType,
|
||||||
|
{ labelKey: string; color: string; bgColor: string; borderColor: string }
|
||||||
|
> = {
|
||||||
|
balance: {
|
||||||
|
labelKey: 'admin.campaigns.bonusType.balance',
|
||||||
|
color: 'text-success-400',
|
||||||
|
bgColor: 'bg-success-500/10',
|
||||||
|
borderColor: 'border-success-500/30',
|
||||||
|
},
|
||||||
|
subscription: {
|
||||||
|
labelKey: 'admin.campaigns.bonusType.subscription',
|
||||||
|
color: 'text-accent-400',
|
||||||
|
bgColor: 'bg-accent-500/10',
|
||||||
|
borderColor: 'border-accent-500/30',
|
||||||
|
},
|
||||||
|
tariff: {
|
||||||
|
labelKey: 'admin.campaigns.bonusType.tariff',
|
||||||
|
color: 'text-accent-400',
|
||||||
|
bgColor: 'bg-accent-500/10',
|
||||||
|
borderColor: 'border-accent-500/30',
|
||||||
|
},
|
||||||
|
none: {
|
||||||
|
labelKey: 'admin.campaigns.bonusType.none',
|
||||||
|
color: 'text-dark-400',
|
||||||
|
bgColor: 'bg-dark-500/10',
|
||||||
|
borderColor: 'border-dark-500/30',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Server selector component
|
||||||
|
function ServerSelector({
|
||||||
|
servers,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
servers: ServerSquadInfo[];
|
||||||
|
selected: string[];
|
||||||
|
onToggle: (uuid: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (servers.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.campaigns.form.servers')}
|
||||||
|
</label>
|
||||||
|
<div className="max-h-48 space-y-2 overflow-y-auto rounded-lg border border-dark-700 bg-dark-800 p-3">
|
||||||
|
{servers.map((server) => (
|
||||||
|
<button
|
||||||
|
key={server.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggle(server.squad_uuid)}
|
||||||
|
className={`flex w-full items-center gap-3 rounded-lg p-3 text-left transition-colors ${
|
||||||
|
selected.includes(server.squad_uuid)
|
||||||
|
? 'bg-accent-500/20 text-accent-300'
|
||||||
|
: 'bg-dark-700 text-dark-300 hover:bg-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex h-5 w-5 items-center justify-center rounded ${
|
||||||
|
selected.includes(server.squad_uuid) ? 'bg-accent-500 text-white' : 'bg-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selected.includes(server.squad_uuid) && <CheckIcon />}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">{server.display_name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tariff selector component
|
||||||
|
function TariffSelector({
|
||||||
|
tariffs,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
tariffs: TariffListItem[];
|
||||||
|
value: number | null;
|
||||||
|
onChange: (id: number | null) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.campaigns.form.selectTariff')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => onChange(e.target.value ? parseInt(e.target.value) : null)}
|
||||||
|
className="input"
|
||||||
|
>
|
||||||
|
<option value="">{t('admin.campaigns.form.notSelected')}</option>
|
||||||
|
{tariffs.map((tariff) => (
|
||||||
|
<option key={tariff.id} value={tariff.id}>
|
||||||
|
{tariff.name} ({tariff.traffic_limit_gb} GB, {tariff.device_limit}{' '}
|
||||||
|
{t('admin.campaigns.form.devices')})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminCampaignCreate() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
useBackButton(() => navigate('/admin/campaigns'));
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [startParameter, setStartParameter] = useState('');
|
||||||
|
const [bonusType, setBonusType] = useState<CampaignBonusType>('balance');
|
||||||
|
const [isActive, setIsActive] = useState(true);
|
||||||
|
|
||||||
|
// Balance bonus
|
||||||
|
const [balanceBonusRubles, setBalanceBonusRubles] = useState<number | ''>(0);
|
||||||
|
|
||||||
|
// Subscription bonus
|
||||||
|
const [subscriptionDays, setSubscriptionDays] = useState<number | ''>(7);
|
||||||
|
const [subscriptionTraffic, setSubscriptionTraffic] = useState<number | ''>(10);
|
||||||
|
const [subscriptionDevices, setSubscriptionDevices] = useState<number | ''>(1);
|
||||||
|
const [selectedSquads, setSelectedSquads] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Tariff bonus
|
||||||
|
const [tariffId, setTariffId] = useState<number | null>(null);
|
||||||
|
const [tariffDays, setTariffDays] = useState<number | ''>(30);
|
||||||
|
|
||||||
|
// Fetch servers
|
||||||
|
const { data: servers = [] } = useQuery({
|
||||||
|
queryKey: ['admin-campaigns-servers'],
|
||||||
|
queryFn: () => campaignsApi.getAvailableServers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch tariffs
|
||||||
|
const { data: tariffs = [] } = useQuery({
|
||||||
|
queryKey: ['admin-campaigns-tariffs'],
|
||||||
|
queryFn: () => campaignsApi.getAvailableTariffs(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create mutation
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: campaignsApi.createCampaign,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-campaigns'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-campaigns-overview'] });
|
||||||
|
navigate('/admin/campaigns');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleServer = (uuid: string) => {
|
||||||
|
setSelectedSquads((prev) =>
|
||||||
|
prev.includes(uuid) ? prev.filter((s) => s !== uuid) : [...prev, uuid],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const data: CampaignCreateRequest = {
|
||||||
|
name,
|
||||||
|
start_parameter: startParameter,
|
||||||
|
bonus_type: bonusType,
|
||||||
|
is_active: isActive,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (bonusType === 'balance') {
|
||||||
|
data.balance_bonus_kopeks = Math.round(toNumber(balanceBonusRubles) * 100);
|
||||||
|
} else if (bonusType === 'subscription') {
|
||||||
|
data.subscription_duration_days = toNumber(subscriptionDays, 7);
|
||||||
|
data.subscription_traffic_gb = toNumber(subscriptionTraffic, 10);
|
||||||
|
data.subscription_device_limit = toNumber(subscriptionDevices, 1);
|
||||||
|
data.subscription_squads = selectedSquads;
|
||||||
|
} else if (bonusType === 'tariff') {
|
||||||
|
data.tariff_id = tariffId || undefined;
|
||||||
|
data.tariff_duration_days = toNumber(tariffDays, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
createMutation.mutate(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValid = name.trim() && startParameter.trim() && /^[a-zA-Z0-9_-]+$/.test(startParameter);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<AdminBackButton />
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-lg bg-accent-500/20 p-2 text-accent-400">
|
||||||
|
<CampaignIcon />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-dark-100">
|
||||||
|
{t('admin.campaigns.modal.createTitle')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-dark-400">{t('admin.campaigns.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Basic Info */}
|
||||||
|
<div className="card space-y-4">
|
||||||
|
{/* Name */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.campaigns.form.name')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder={t('admin.campaigns.form.namePlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Start Parameter */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.campaigns.form.startParameter')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={startParameter}
|
||||||
|
onChange={(e) => setStartParameter(e.target.value.replace(/[^a-zA-Z0-9_-]/g, ''))}
|
||||||
|
className="input font-mono"
|
||||||
|
placeholder="instagram_jan2024"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-dark-500">
|
||||||
|
{t('admin.campaigns.form.startParameterHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active toggle */}
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-dark-700 bg-dark-800 p-4">
|
||||||
|
<span className="text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.campaigns.form.active')}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsActive(!isActive)}
|
||||||
|
className={`relative h-6 w-11 rounded-full transition-colors ${
|
||||||
|
isActive ? 'bg-accent-500' : 'bg-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||||
|
isActive ? 'left-6' : 'left-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bonus Type */}
|
||||||
|
<div className="card space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold text-dark-100">
|
||||||
|
{t('admin.campaigns.form.bonusType')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{(Object.keys(bonusTypeConfig) as CampaignBonusType[]).map((type) => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBonusType(type)}
|
||||||
|
className={`rounded-lg border p-4 text-left transition-all ${
|
||||||
|
bonusType === type
|
||||||
|
? `${bonusTypeConfig[type].bgColor} ${bonusTypeConfig[type].borderColor} ${bonusTypeConfig[type].color}`
|
||||||
|
: 'border-dark-700 bg-dark-800 text-dark-300 hover:border-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium">{t(bonusTypeConfig[type].labelKey)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bonus Settings */}
|
||||||
|
{bonusType === 'balance' && (
|
||||||
|
<div
|
||||||
|
className={`card space-y-4 border ${bonusTypeConfig.balance.borderColor} ${bonusTypeConfig.balance.bgColor}`}
|
||||||
|
>
|
||||||
|
<h2 className={`text-lg font-semibold ${bonusTypeConfig.balance.color}`}>
|
||||||
|
{t('admin.campaigns.form.balanceBonus')}
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={balanceBonusRubles}
|
||||||
|
onChange={createNumberInputHandler(setBalanceBonusRubles, 0)}
|
||||||
|
className="input w-32"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
<span className="text-dark-300">₽</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bonusType === 'subscription' && (
|
||||||
|
<div
|
||||||
|
className={`card space-y-4 border ${bonusTypeConfig.subscription.borderColor} ${bonusTypeConfig.subscription.bgColor}`}
|
||||||
|
>
|
||||||
|
<h2 className={`text-lg font-semibold ${bonusTypeConfig.subscription.color}`}>
|
||||||
|
{t('admin.campaigns.form.trialSubscription')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm text-dark-400">
|
||||||
|
{t('admin.campaigns.form.days')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={subscriptionDays}
|
||||||
|
onChange={createNumberInputHandler(setSubscriptionDays, 1)}
|
||||||
|
className="input"
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm text-dark-400">
|
||||||
|
{t('admin.campaigns.form.trafficGb')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={subscriptionTraffic}
|
||||||
|
onChange={createNumberInputHandler(setSubscriptionTraffic, 0)}
|
||||||
|
className="input"
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm text-dark-400">
|
||||||
|
{t('admin.campaigns.form.devices')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={subscriptionDevices}
|
||||||
|
onChange={createNumberInputHandler(setSubscriptionDevices, 1)}
|
||||||
|
className="input"
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ServerSelector servers={servers} selected={selectedSquads} onToggle={toggleServer} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bonusType === 'tariff' && (
|
||||||
|
<div
|
||||||
|
className={`card space-y-4 border ${bonusTypeConfig.tariff.borderColor} ${bonusTypeConfig.tariff.bgColor}`}
|
||||||
|
>
|
||||||
|
<h2 className={`text-lg font-semibold ${bonusTypeConfig.tariff.color}`}>
|
||||||
|
{t('admin.campaigns.form.tariff')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<TariffSelector tariffs={tariffs} value={tariffId} onChange={setTariffId} />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.campaigns.form.durationDays')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={tariffDays}
|
||||||
|
onChange={createNumberInputHandler(setTariffDays, 1)}
|
||||||
|
className="input w-32"
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bonusType === 'none' && (
|
||||||
|
<div
|
||||||
|
className={`card border ${bonusTypeConfig.none.borderColor} ${bonusTypeConfig.none.bgColor}`}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-dark-400">{t('admin.campaigns.form.noBonusDescription')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="card flex items-center justify-end gap-3">
|
||||||
|
<button onClick={() => navigate('/admin/campaigns')} className="btn-secondary">
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!isValid || createMutation.isPending}
|
||||||
|
className="btn-primary flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? <RefreshIcon /> : <CampaignIcon />}
|
||||||
|
{t('admin.campaigns.form.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
494
src/pages/AdminCampaignEdit.tsx
Normal file
494
src/pages/AdminCampaignEdit.tsx
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
campaignsApi,
|
||||||
|
CampaignUpdateRequest,
|
||||||
|
CampaignBonusType,
|
||||||
|
ServerSquadInfo,
|
||||||
|
TariffListItem,
|
||||||
|
} from '../api/campaigns';
|
||||||
|
import { AdminBackButton } from '../components/admin';
|
||||||
|
import { CheckIcon, CampaignIcon } from '../components/icons';
|
||||||
|
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
|
||||||
|
import { useBackButton } from '../platform/hooks/useBackButton';
|
||||||
|
|
||||||
|
// Bonus type config
|
||||||
|
const bonusTypeConfig: Record<
|
||||||
|
CampaignBonusType,
|
||||||
|
{ labelKey: string; color: string; bgColor: string; borderColor: string }
|
||||||
|
> = {
|
||||||
|
balance: {
|
||||||
|
labelKey: 'admin.campaigns.bonusType.balance',
|
||||||
|
color: 'text-success-400',
|
||||||
|
bgColor: 'bg-success-500/10',
|
||||||
|
borderColor: 'border-success-500/30',
|
||||||
|
},
|
||||||
|
subscription: {
|
||||||
|
labelKey: 'admin.campaigns.bonusType.subscription',
|
||||||
|
color: 'text-accent-400',
|
||||||
|
bgColor: 'bg-accent-500/10',
|
||||||
|
borderColor: 'border-accent-500/30',
|
||||||
|
},
|
||||||
|
tariff: {
|
||||||
|
labelKey: 'admin.campaigns.bonusType.tariff',
|
||||||
|
color: 'text-accent-400',
|
||||||
|
bgColor: 'bg-accent-500/10',
|
||||||
|
borderColor: 'border-accent-500/30',
|
||||||
|
},
|
||||||
|
none: {
|
||||||
|
labelKey: 'admin.campaigns.bonusType.none',
|
||||||
|
color: 'text-dark-400',
|
||||||
|
bgColor: 'bg-dark-500/10',
|
||||||
|
borderColor: 'border-dark-500/30',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Server selector component
|
||||||
|
function ServerSelector({
|
||||||
|
servers,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
servers: ServerSquadInfo[];
|
||||||
|
selected: string[];
|
||||||
|
onToggle: (uuid: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (servers.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.campaigns.form.servers')}
|
||||||
|
</label>
|
||||||
|
<div className="max-h-48 space-y-2 overflow-y-auto rounded-lg border border-dark-700 bg-dark-800 p-3">
|
||||||
|
{servers.map((server) => (
|
||||||
|
<button
|
||||||
|
key={server.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggle(server.squad_uuid)}
|
||||||
|
className={`flex w-full items-center gap-3 rounded-lg p-3 text-left transition-colors ${
|
||||||
|
selected.includes(server.squad_uuid)
|
||||||
|
? 'bg-accent-500/20 text-accent-300'
|
||||||
|
: 'bg-dark-700 text-dark-300 hover:bg-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex h-5 w-5 items-center justify-center rounded ${
|
||||||
|
selected.includes(server.squad_uuid) ? 'bg-accent-500 text-white' : 'bg-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selected.includes(server.squad_uuid) && <CheckIcon />}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">{server.display_name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tariff selector component
|
||||||
|
function TariffSelector({
|
||||||
|
tariffs,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
tariffs: TariffListItem[];
|
||||||
|
value: number | null;
|
||||||
|
onChange: (id: number | null) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.campaigns.form.selectTariff')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => onChange(e.target.value ? parseInt(e.target.value) : null)}
|
||||||
|
className="input"
|
||||||
|
>
|
||||||
|
<option value="">{t('admin.campaigns.form.notSelected')}</option>
|
||||||
|
{tariffs.map((tariff) => (
|
||||||
|
<option key={tariff.id} value={tariff.id}>
|
||||||
|
{tariff.name} ({tariff.traffic_limit_gb} GB, {tariff.device_limit}{' '}
|
||||||
|
{t('admin.campaigns.form.devices')})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminCampaignEdit() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const campaignId = parseInt(id || '0');
|
||||||
|
|
||||||
|
useBackButton(() => navigate('/admin/campaigns'));
|
||||||
|
|
||||||
|
// Fetch campaign
|
||||||
|
const {
|
||||||
|
data: campaign,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['admin-campaign', campaignId],
|
||||||
|
queryFn: () => campaignsApi.getCampaign(campaignId),
|
||||||
|
enabled: campaignId > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch servers
|
||||||
|
const { data: servers = [] } = useQuery({
|
||||||
|
queryKey: ['admin-campaigns-servers'],
|
||||||
|
queryFn: () => campaignsApi.getAvailableServers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch tariffs
|
||||||
|
const { data: tariffs = [] } = useQuery({
|
||||||
|
queryKey: ['admin-campaigns-tariffs'],
|
||||||
|
queryFn: () => campaignsApi.getAvailableTariffs(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [startParameter, setStartParameter] = useState('');
|
||||||
|
const [bonusType, setBonusType] = useState<CampaignBonusType>('balance');
|
||||||
|
const [isActive, setIsActive] = useState(true);
|
||||||
|
|
||||||
|
// Balance bonus
|
||||||
|
const [balanceBonusRubles, setBalanceBonusRubles] = useState<number | ''>(0);
|
||||||
|
|
||||||
|
// Subscription bonus
|
||||||
|
const [subscriptionDays, setSubscriptionDays] = useState<number | ''>(7);
|
||||||
|
const [subscriptionTraffic, setSubscriptionTraffic] = useState<number | ''>(10);
|
||||||
|
const [subscriptionDevices, setSubscriptionDevices] = useState<number | ''>(1);
|
||||||
|
const [selectedSquads, setSelectedSquads] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Tariff bonus
|
||||||
|
const [tariffId, setTariffId] = useState<number | null>(null);
|
||||||
|
const [tariffDays, setTariffDays] = useState<number | ''>(30);
|
||||||
|
|
||||||
|
// Initialize form when campaign loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (campaign) {
|
||||||
|
setName(campaign.name || '');
|
||||||
|
setStartParameter(campaign.start_parameter || '');
|
||||||
|
setBonusType(campaign.bonus_type || 'balance');
|
||||||
|
setIsActive(campaign.is_active ?? true);
|
||||||
|
setBalanceBonusRubles((campaign.balance_bonus_kopeks || 0) / 100);
|
||||||
|
setSubscriptionDays(campaign.subscription_duration_days || 7);
|
||||||
|
setSubscriptionTraffic(campaign.subscription_traffic_gb || 10);
|
||||||
|
setSubscriptionDevices(campaign.subscription_device_limit || 1);
|
||||||
|
setSelectedSquads(campaign.subscription_squads || []);
|
||||||
|
setTariffId(campaign.tariff_id || null);
|
||||||
|
setTariffDays(campaign.tariff_duration_days || 30);
|
||||||
|
}
|
||||||
|
}, [campaign]);
|
||||||
|
|
||||||
|
// Update mutation
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (data: CampaignUpdateRequest) => campaignsApi.updateCampaign(campaignId, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-campaigns'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-campaign', campaignId] });
|
||||||
|
navigate('/admin/campaigns');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleServer = (uuid: string) => {
|
||||||
|
setSelectedSquads((prev) =>
|
||||||
|
prev.includes(uuid) ? prev.filter((s) => s !== uuid) : [...prev, uuid],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const data: CampaignUpdateRequest = {
|
||||||
|
name,
|
||||||
|
start_parameter: startParameter,
|
||||||
|
bonus_type: bonusType,
|
||||||
|
is_active: isActive,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (bonusType === 'balance') {
|
||||||
|
data.balance_bonus_kopeks = Math.round(toNumber(balanceBonusRubles) * 100);
|
||||||
|
} else if (bonusType === 'subscription') {
|
||||||
|
data.subscription_duration_days = toNumber(subscriptionDays, 7);
|
||||||
|
data.subscription_traffic_gb = toNumber(subscriptionTraffic, 10);
|
||||||
|
data.subscription_device_limit = toNumber(subscriptionDevices, 1);
|
||||||
|
data.subscription_squads = selectedSquads;
|
||||||
|
} else if (bonusType === 'tariff') {
|
||||||
|
data.tariff_id = tariffId || undefined;
|
||||||
|
data.tariff_duration_days = toNumber(tariffDays, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMutation.mutate(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValid = name.trim() && startParameter.trim() && /^[a-zA-Z0-9_-]+$/.test(startParameter);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !campaign) {
|
||||||
|
return (
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
<div className="mb-6 flex items-center gap-3">
|
||||||
|
<AdminBackButton to="/admin/campaigns" />
|
||||||
|
<h1 className="text-xl font-semibold text-dark-100">
|
||||||
|
{t('admin.campaigns.modal.editTitle')}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-6 text-center">
|
||||||
|
<p className="text-error-400">{t('admin.campaigns.loadError')}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/admin/campaigns')}
|
||||||
|
className="mt-4 text-sm text-dark-400 hover:text-dark-200"
|
||||||
|
>
|
||||||
|
{t('common.back')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-fade-in space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<AdminBackButton to="/admin/campaigns" />
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-lg bg-accent-500/20 p-2 text-accent-400">
|
||||||
|
<CampaignIcon />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-dark-100">
|
||||||
|
{t('admin.campaigns.modal.editTitle')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-dark-400">{campaign.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Basic Info */}
|
||||||
|
<div className="card space-y-4">
|
||||||
|
{/* Name */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.campaigns.form.name')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder={t('admin.campaigns.form.namePlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Start Parameter */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.campaigns.form.startParameter')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={startParameter}
|
||||||
|
onChange={(e) => setStartParameter(e.target.value.replace(/[^a-zA-Z0-9_-]/g, ''))}
|
||||||
|
className="input font-mono"
|
||||||
|
placeholder="instagram_jan2024"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-dark-500">
|
||||||
|
{t('admin.campaigns.form.startParameterHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active toggle */}
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-dark-700 bg-dark-800 p-4">
|
||||||
|
<span className="text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.campaigns.form.active')}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsActive(!isActive)}
|
||||||
|
className={`relative h-6 w-11 rounded-full transition-colors ${
|
||||||
|
isActive ? 'bg-accent-500' : 'bg-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||||
|
isActive ? 'left-6' : 'left-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bonus Type */}
|
||||||
|
<div className="card space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold text-dark-100">
|
||||||
|
{t('admin.campaigns.form.bonusType')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{(Object.keys(bonusTypeConfig) as CampaignBonusType[]).map((type) => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBonusType(type)}
|
||||||
|
className={`rounded-lg border p-4 text-left transition-all ${
|
||||||
|
bonusType === type
|
||||||
|
? `${bonusTypeConfig[type].bgColor} ${bonusTypeConfig[type].borderColor} ${bonusTypeConfig[type].color}`
|
||||||
|
: 'border-dark-700 bg-dark-800 text-dark-300 hover:border-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium">{t(bonusTypeConfig[type].labelKey)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bonus Settings */}
|
||||||
|
{bonusType === 'balance' && (
|
||||||
|
<div
|
||||||
|
className={`card space-y-4 border ${bonusTypeConfig.balance.borderColor} ${bonusTypeConfig.balance.bgColor}`}
|
||||||
|
>
|
||||||
|
<h2 className={`text-lg font-semibold ${bonusTypeConfig.balance.color}`}>
|
||||||
|
{t('admin.campaigns.form.balanceBonus')}
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={balanceBonusRubles}
|
||||||
|
onChange={createNumberInputHandler(setBalanceBonusRubles, 0)}
|
||||||
|
className="input w-32"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
<span className="text-dark-300">₽</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bonusType === 'subscription' && (
|
||||||
|
<div
|
||||||
|
className={`card space-y-4 border ${bonusTypeConfig.subscription.borderColor} ${bonusTypeConfig.subscription.bgColor}`}
|
||||||
|
>
|
||||||
|
<h2 className={`text-lg font-semibold ${bonusTypeConfig.subscription.color}`}>
|
||||||
|
{t('admin.campaigns.form.trialSubscription')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm text-dark-400">
|
||||||
|
{t('admin.campaigns.form.days')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={subscriptionDays}
|
||||||
|
onChange={createNumberInputHandler(setSubscriptionDays, 1)}
|
||||||
|
className="input"
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm text-dark-400">
|
||||||
|
{t('admin.campaigns.form.trafficGb')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={subscriptionTraffic}
|
||||||
|
onChange={createNumberInputHandler(setSubscriptionTraffic, 0)}
|
||||||
|
className="input"
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm text-dark-400">
|
||||||
|
{t('admin.campaigns.form.devices')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={subscriptionDevices}
|
||||||
|
onChange={createNumberInputHandler(setSubscriptionDevices, 1)}
|
||||||
|
className="input"
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ServerSelector servers={servers} selected={selectedSquads} onToggle={toggleServer} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bonusType === 'tariff' && (
|
||||||
|
<div
|
||||||
|
className={`card space-y-4 border ${bonusTypeConfig.tariff.borderColor} ${bonusTypeConfig.tariff.bgColor}`}
|
||||||
|
>
|
||||||
|
<h2 className={`text-lg font-semibold ${bonusTypeConfig.tariff.color}`}>
|
||||||
|
{t('admin.campaigns.form.tariff')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<TariffSelector tariffs={tariffs} value={tariffId} onChange={setTariffId} />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.campaigns.form.durationDays')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={tariffDays}
|
||||||
|
onChange={createNumberInputHandler(setTariffDays, 1)}
|
||||||
|
className="input w-32"
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bonusType === 'none' && (
|
||||||
|
<div
|
||||||
|
className={`card border ${bonusTypeConfig.none.borderColor} ${bonusTypeConfig.none.bgColor}`}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-dark-400">{t('admin.campaigns.form.noBonusDescription')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="card flex items-center justify-end gap-3">
|
||||||
|
<button onClick={() => navigate('/admin/campaigns')} className="btn-secondary">
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!isValid || updateMutation.isPending}
|
||||||
|
className="btn-primary flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? (
|
||||||
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
|
) : null}
|
||||||
|
{updateMutation.isPending ? t('common.saving') : t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{updateMutation.isError && (
|
||||||
|
<div className="rounded-lg border border-error-500/30 bg-error-500/10 p-3 text-sm text-error-400">
|
||||||
|
{t('admin.campaigns.updateError')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
381
src/pages/AdminCampaignStats.tsx
Normal file
381
src/pages/AdminCampaignStats.tsx
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { campaignsApi, CampaignBonusType } from '../api/campaigns';
|
||||||
|
import { AdminBackButton } from '../components/admin';
|
||||||
|
import { useBackButton } from '../platform/hooks/useBackButton';
|
||||||
|
|
||||||
|
// Icons
|
||||||
|
const CopyIcon = () => (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const LinkIcon = () => (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244"
|
||||||
|
/>
|
||||||
|
</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 ChartIcon = () => (
|
||||||
|
<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 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bonus type config
|
||||||
|
const bonusTypeConfig: Record<
|
||||||
|
CampaignBonusType,
|
||||||
|
{ labelKey: string; color: string; bgColor: string }
|
||||||
|
> = {
|
||||||
|
balance: {
|
||||||
|
labelKey: 'admin.campaigns.bonusType.balance',
|
||||||
|
color: 'text-success-400',
|
||||||
|
bgColor: 'bg-success-500/20',
|
||||||
|
},
|
||||||
|
subscription: {
|
||||||
|
labelKey: 'admin.campaigns.bonusType.subscription',
|
||||||
|
color: 'text-accent-400',
|
||||||
|
bgColor: 'bg-accent-500/20',
|
||||||
|
},
|
||||||
|
tariff: {
|
||||||
|
labelKey: 'admin.campaigns.bonusType.tariff',
|
||||||
|
color: 'text-purple-400',
|
||||||
|
bgColor: 'bg-purple-500/20',
|
||||||
|
},
|
||||||
|
none: {
|
||||||
|
labelKey: 'admin.campaigns.bonusType.none',
|
||||||
|
color: 'text-dark-400',
|
||||||
|
bgColor: 'bg-dark-600',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
const formatRubles = (kopeks: number): string => {
|
||||||
|
const rubles = kopeks / 100;
|
||||||
|
return `${rubles.toLocaleString('ru-RU')} ₽`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: string | null): string => {
|
||||||
|
if (!date) return '-';
|
||||||
|
return new Date(date).toLocaleDateString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminCampaignStats() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [showUsers, setShowUsers] = useState(false);
|
||||||
|
|
||||||
|
useBackButton(() => navigate('/admin/campaigns'));
|
||||||
|
|
||||||
|
// Fetch stats
|
||||||
|
const {
|
||||||
|
data: stats,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['campaign-stats', id],
|
||||||
|
queryFn: () => campaignsApi.getCampaignStats(Number(id)),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch registrations when users section is open
|
||||||
|
const { data: registrationsData, isLoading: usersLoading } = useQuery({
|
||||||
|
queryKey: ['campaign-registrations', id],
|
||||||
|
queryFn: () => campaignsApi.getCampaignRegistrations(Number(id), 1, 50),
|
||||||
|
enabled: !!id && showUsers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const copyLink = () => {
|
||||||
|
if (stats?.deep_link) {
|
||||||
|
navigator.clipboard.writeText(stats.deep_link);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !stats) {
|
||||||
|
return (
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
<div className="mb-6 flex items-center gap-3">
|
||||||
|
<AdminBackButton to="/admin/campaigns" />
|
||||||
|
<h1 className="text-xl font-semibold text-dark-100">
|
||||||
|
{t('admin.campaigns.stats.title')}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-6 text-center">
|
||||||
|
<p className="text-error-400">{t('admin.campaigns.stats.loadError')}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/admin/campaigns')}
|
||||||
|
className="mt-4 text-sm text-dark-400 hover:text-dark-200"
|
||||||
|
>
|
||||||
|
{t('common.back')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<AdminBackButton to="/admin/campaigns" />
|
||||||
|
<div className="rounded-lg bg-accent-500/20 p-2 text-accent-400">
|
||||||
|
<ChartIcon />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-dark-100">{stats.name}</h1>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`rounded px-2 py-0.5 text-xs ${bonusTypeConfig[stats.bonus_type].bgColor} ${bonusTypeConfig[stats.bonus_type].color}`}
|
||||||
|
>
|
||||||
|
{t(bonusTypeConfig[stats.bonus_type].labelKey)}
|
||||||
|
</span>
|
||||||
|
{stats.is_active ? (
|
||||||
|
<span className="rounded bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
|
||||||
|
{t('admin.campaigns.stats.active')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
|
||||||
|
{t('admin.campaigns.stats.inactive')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Deep Link */}
|
||||||
|
{stats.deep_link && (
|
||||||
|
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<LinkIcon />
|
||||||
|
<span className="truncate text-sm text-dark-300">{stats.deep_link}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={copyLink}
|
||||||
|
className="flex shrink-0 items-center gap-1 rounded-lg bg-dark-700 px-3 py-1.5 text-dark-300 transition-colors hover:bg-dark-600"
|
||||||
|
>
|
||||||
|
<CopyIcon />
|
||||||
|
<span className="text-sm">
|
||||||
|
{copied ? t('admin.campaigns.stats.copied') : t('admin.campaigns.stats.copy')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main Stats */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
|
||||||
|
<div className="text-2xl font-bold text-dark-100">{stats.registrations}</div>
|
||||||
|
<div className="text-xs text-dark-500">{t('admin.campaigns.stats.registrations')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
|
||||||
|
<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-xl border border-dark-700 bg-dark-800 p-4 text-center">
|
||||||
|
<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-xl border border-dark-700 bg-dark-800 p-4 text-center">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Detailed Stats */}
|
||||||
|
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||||
|
<h3 className="mb-4 font-medium text-dark-200">
|
||||||
|
{t('admin.campaigns.stats.detailedStats')}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
|
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||||
|
<div className="mb-1 text-sm text-dark-400">
|
||||||
|
{t('admin.campaigns.stats.bonusesIssued')}
|
||||||
|
</div>
|
||||||
|
{stats.bonus_type === 'balance' && (
|
||||||
|
<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-accent-400">
|
||||||
|
{t('admin.campaigns.stats.subscriptionsIssued', {
|
||||||
|
count: stats.subscription_issued,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{stats.bonus_type === 'tariff' && (
|
||||||
|
<div className="text-lg font-medium text-accent-400">
|
||||||
|
{t('admin.campaigns.stats.tariffsIssued', { count: stats.subscription_issued })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{stats.bonus_type === 'none' && (
|
||||||
|
<div className="text-lg font-medium text-dark-400">-</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||||
|
<div className="mb-1 text-sm text-dark-400">
|
||||||
|
{t('admin.campaigns.stats.avgRevenuePerUser')}
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-medium text-dark-200">
|
||||||
|
{formatRubles(stats.avg_revenue_per_user_kopeks)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||||
|
<div className="mb-1 text-sm text-dark-400">
|
||||||
|
{t('admin.campaigns.stats.avgFirstPayment')}
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-medium text-dark-200">
|
||||||
|
{formatRubles(stats.avg_first_payment_kopeks)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||||
|
<div className="mb-1 text-sm text-dark-400">
|
||||||
|
{t('admin.campaigns.stats.trialSubscriptions')}
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-medium text-dark-200">
|
||||||
|
{t('admin.campaigns.stats.trialCount', {
|
||||||
|
total: stats.trial_users_count,
|
||||||
|
active: stats.active_trials_count,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||||
|
<div className="mb-1 text-sm text-dark-400">
|
||||||
|
{t('admin.campaigns.stats.trialConversion')}
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-medium text-dark-200">
|
||||||
|
{stats.trial_conversion_rate}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||||
|
<div className="mb-1 text-sm text-dark-400">
|
||||||
|
{t('admin.campaigns.stats.lastRegistration')}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-dark-200">
|
||||||
|
{formatDate(stats.last_registration)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Users Section */}
|
||||||
|
<div className="rounded-xl border border-dark-700 bg-dark-800">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUsers(!showUsers)}
|
||||||
|
className="flex w-full items-center justify-between p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UsersIcon />
|
||||||
|
<span className="font-medium text-dark-200">
|
||||||
|
{t('admin.campaigns.stats.users')} ({stats.registrations})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
className={`h-5 w-5 text-dark-400 transition-transform ${showUsers ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showUsers && (
|
||||||
|
<div className="border-t border-dark-700 p-4">
|
||||||
|
{usersLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : registrationsData?.registrations.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-dark-500">
|
||||||
|
{t('admin.campaigns.stats.noUsers')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{registrationsData?.registrations.map((reg) => (
|
||||||
|
<Link
|
||||||
|
key={reg.id}
|
||||||
|
to={`/admin/users/${reg.user_id}`}
|
||||||
|
className="flex items-center justify-between rounded-lg bg-dark-700/50 p-3 transition-colors hover:bg-dark-700"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-dark-100">
|
||||||
|
{reg.first_name || reg.username || `User #${reg.user_id}`}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-dark-500">{reg.telegram_id}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{reg.has_paid && (
|
||||||
|
<span className="rounded bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
|
||||||
|
{t('admin.campaigns.stats.paid')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{reg.has_subscription && (
|
||||||
|
<span className="rounded bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
|
||||||
|
{t('admin.campaigns.stats.hasSub')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-dark-500">{formatDate(reg.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import {
|
import {
|
||||||
statsApi,
|
statsApi,
|
||||||
type DashboardStats,
|
type DashboardStats,
|
||||||
@@ -10,8 +10,11 @@ import {
|
|||||||
type RecentPaymentsResponse,
|
type RecentPaymentsResponse,
|
||||||
} from '../api/admin';
|
} from '../api/admin';
|
||||||
import { useCurrency } from '../hooks/useCurrency';
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
|
import { useBackButton } from '../platform/hooks/useBackButton';
|
||||||
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
|
|
||||||
// Icons - styled like main navigation
|
// Icons - styled like main navigation
|
||||||
|
|
||||||
const BackIcon = () => (
|
const BackIcon = () => (
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5 text-dark-400"
|
className="h-5 w-5 text-dark-400"
|
||||||
@@ -358,7 +361,13 @@ function RevenueChart({ data }: { data: { date: string; amount_rubles: number }[
|
|||||||
|
|
||||||
export default function AdminDashboard() {
|
export default function AdminDashboard() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { formatAmount, currencySymbol } = useCurrency();
|
const { formatAmount, currencySymbol } = useCurrency();
|
||||||
|
const { capabilities } = usePlatform();
|
||||||
|
|
||||||
|
// Use native Telegram back button in Mini App
|
||||||
|
useBackButton(() => navigate('/admin'));
|
||||||
|
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -461,12 +470,15 @@ export default function AdminDashboard() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link
|
{/* Show back button only on web, not in Telegram Mini App */}
|
||||||
to="/admin"
|
{!capabilities.hasBackButton && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/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"
|
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 />
|
<BackIcon />
|
||||||
</Link>
|
</button>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-dark-100">{t('adminDashboard.title')}</h1>
|
<h1 className="text-2xl font-bold text-dark-100">{t('adminDashboard.title')}</h1>
|
||||||
<p className="text-dark-400">{t('adminDashboard.subtitle')}</p>
|
<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,34 @@
|
|||||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import {
|
import {
|
||||||
adminEmailTemplatesApi,
|
adminEmailTemplatesApi,
|
||||||
EmailTemplateType,
|
EmailTemplateType,
|
||||||
EmailTemplateDetail,
|
EmailTemplateDetail,
|
||||||
EmailTemplateLanguageData,
|
EmailTemplateLanguageData,
|
||||||
} from '../api/adminEmailTemplates';
|
} from '../api/adminEmailTemplates';
|
||||||
|
import { AdminBackButton, BackIcon } from '../components/admin';
|
||||||
|
import { useIsTelegram } from '../platform/hooks/usePlatform';
|
||||||
|
import { useNotify } from '@/platform';
|
||||||
|
|
||||||
|
// 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
|
// 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 = () => (
|
const MailIcon = () => (
|
||||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
@@ -69,12 +77,6 @@ const ResetIcon = () => (
|
|||||||
</svg>
|
</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> = {
|
const LANG_LABELS: Record<string, string> = {
|
||||||
ru: 'RU',
|
ru: 'RU',
|
||||||
en: 'EN',
|
en: 'EN',
|
||||||
@@ -156,16 +158,22 @@ function TemplateEditor({
|
|||||||
currentLang: string;
|
currentLang: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const notify = useNotify();
|
||||||
|
const isTelegram = useIsTelegram();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const isPreviewDisabled = isTelegram || isMobile;
|
||||||
|
|
||||||
const [activeLang, setActiveLang] = useState('ru');
|
const [activeLang, setActiveLang] = useState('ru');
|
||||||
const [editSubject, setEditSubject] = useState('');
|
const [editSubject, setEditSubject] = useState('');
|
||||||
const [editBody, setEditBody] = useState('');
|
const [editBody, setEditBody] = useState('');
|
||||||
const [isDirty, setIsDirty] = useState(false);
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
const [toast, setToast] = useState<{
|
||||||
const [previewHtml, setPreviewHtml] = useState('');
|
type: 'success' | 'error' | 'info';
|
||||||
const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
message: string;
|
||||||
|
} | null>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
||||||
|
|
||||||
const langData: EmailTemplateLanguageData | undefined = detail.languages[activeLang];
|
const langData: EmailTemplateLanguageData | undefined = detail.languages[activeLang];
|
||||||
|
|
||||||
@@ -178,7 +186,7 @@ function TemplateEditor({
|
|||||||
}
|
}
|
||||||
}, [activeLang, langData]);
|
}, [activeLang, langData]);
|
||||||
|
|
||||||
const showToast = useCallback((type: 'success' | 'error', message: string) => {
|
const showToast = useCallback((type: 'success' | 'error' | 'info', message: string) => {
|
||||||
setToast({ type, message });
|
setToast({ type, message });
|
||||||
setTimeout(() => setToast(null), 3000);
|
setTimeout(() => setToast(null), 3000);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -245,23 +253,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
|
// Send test mutation
|
||||||
const testMutation = useMutation({
|
const testMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
@@ -276,18 +267,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) => {
|
const handleSubjectChange = (value: string) => {
|
||||||
setEditSubject(value);
|
setEditSubject(value);
|
||||||
setIsDirty(true);
|
setIsDirty(true);
|
||||||
@@ -421,9 +400,22 @@ function TemplateEditor({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => previewMutation.mutate()}
|
onClick={() => {
|
||||||
disabled={previewMutation.isPending}
|
if (isPreviewDisabled) {
|
||||||
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"
|
notify.warning(
|
||||||
|
t('admin.emailTemplates.previewDesktopOnly'),
|
||||||
|
t('admin.emailTemplates.previewNotAvailable'),
|
||||||
|
);
|
||||||
|
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 />
|
<EyeIcon />
|
||||||
{t('admin.emailTemplates.preview')}
|
{t('admin.emailTemplates.preview')}
|
||||||
@@ -460,40 +452,17 @@ function TemplateEditor({
|
|||||||
{/* Toast */}
|
{/* Toast */}
|
||||||
{toast && (
|
{toast && (
|
||||||
<div
|
<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 ${
|
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-emerald-500/90 text-white' : 'bg-red-500/90 text-white'
|
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}
|
{toast.message}
|
||||||
</div>
|
</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>
|
|
||||||
</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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -522,14 +491,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">
|
<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 */}
|
{/* Page Header */}
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
<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" />
|
||||||
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>
|
|
||||||
<div className="flex min-w-0 items-center gap-2 sm:gap-2.5">
|
<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 />
|
<MailIcon />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { RemnawaveIcon } from '../components/icons';
|
||||||
|
|
||||||
// Group header icons
|
// Group header icons
|
||||||
const AnalyticsGroupIcon = () => (
|
const AnalyticsGroupIcon = () => (
|
||||||
@@ -134,6 +135,16 @@ const GiftIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const UserGroupIcon = () => (
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
const MegaphoneIcon = () => (
|
const MegaphoneIcon = () => (
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path
|
<path
|
||||||
@@ -205,16 +216,6 @@ const EnvelopeIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const CubeTransparentIcon = () => (
|
|
||||||
<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 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const ChevronRightIcon = () => (
|
const ChevronRightIcon = () => (
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" 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" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||||
@@ -299,10 +300,10 @@ export default function AdminPanel() {
|
|||||||
id: 'analytics',
|
id: 'analytics',
|
||||||
title: t('admin.groups.analytics'),
|
title: t('admin.groups.analytics'),
|
||||||
icon: <AnalyticsGroupIcon />,
|
icon: <AnalyticsGroupIcon />,
|
||||||
gradient: 'from-emerald-500/10 to-emerald-500/5',
|
gradient: 'from-success-500/10 to-success-500/5',
|
||||||
borderColor: 'border-emerald-500/20',
|
borderColor: 'border-success-500/20',
|
||||||
iconBg: 'bg-emerald-500/20',
|
iconBg: 'bg-success-500/20',
|
||||||
iconColor: 'text-emerald-400',
|
iconColor: 'text-success-400',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
to: '/admin/dashboard',
|
to: '/admin/dashboard',
|
||||||
@@ -322,10 +323,10 @@ export default function AdminPanel() {
|
|||||||
id: 'users',
|
id: 'users',
|
||||||
title: t('admin.groups.users'),
|
title: t('admin.groups.users'),
|
||||||
icon: <UsersGroupIcon />,
|
icon: <UsersGroupIcon />,
|
||||||
gradient: 'from-blue-500/10 to-blue-500/5',
|
gradient: 'from-accent-500/10 to-accent-500/5',
|
||||||
borderColor: 'border-blue-500/20',
|
borderColor: 'border-accent-500/20',
|
||||||
iconBg: 'bg-blue-500/20',
|
iconBg: 'bg-accent-500/20',
|
||||||
iconColor: 'text-blue-400',
|
iconColor: 'text-accent-400',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
to: '/admin/users',
|
to: '/admin/users',
|
||||||
@@ -351,10 +352,10 @@ export default function AdminPanel() {
|
|||||||
id: 'tariffs',
|
id: 'tariffs',
|
||||||
title: t('admin.groups.tariffs'),
|
title: t('admin.groups.tariffs'),
|
||||||
icon: <TariffsGroupIcon />,
|
icon: <TariffsGroupIcon />,
|
||||||
gradient: 'from-amber-500/10 to-amber-500/5',
|
gradient: 'from-warning-500/10 to-warning-500/5',
|
||||||
borderColor: 'border-amber-500/20',
|
borderColor: 'border-warning-500/20',
|
||||||
iconBg: 'bg-amber-500/20',
|
iconBg: 'bg-warning-500/20',
|
||||||
iconColor: 'text-amber-400',
|
iconColor: 'text-warning-400',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
to: '/admin/tariffs',
|
to: '/admin/tariffs',
|
||||||
@@ -368,6 +369,12 @@ export default function AdminPanel() {
|
|||||||
title: t('admin.nav.promocodes'),
|
title: t('admin.nav.promocodes'),
|
||||||
description: t('admin.panel.promocodesDesc'),
|
description: t('admin.panel.promocodesDesc'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: '/admin/promo-groups',
|
||||||
|
icon: <UserGroupIcon />,
|
||||||
|
title: t('admin.nav.promoGroups'),
|
||||||
|
description: t('admin.panel.promoGroupsDesc'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: '/admin/promo-offers',
|
to: '/admin/promo-offers',
|
||||||
icon: <GiftIcon />,
|
icon: <GiftIcon />,
|
||||||
@@ -386,10 +393,10 @@ export default function AdminPanel() {
|
|||||||
id: 'marketing',
|
id: 'marketing',
|
||||||
title: t('admin.groups.marketing'),
|
title: t('admin.groups.marketing'),
|
||||||
icon: <MarketingGroupIcon />,
|
icon: <MarketingGroupIcon />,
|
||||||
gradient: 'from-rose-500/10 to-rose-500/5',
|
gradient: 'from-error-500/10 to-error-500/5',
|
||||||
borderColor: 'border-rose-500/20',
|
borderColor: 'border-error-500/20',
|
||||||
iconBg: 'bg-rose-500/20',
|
iconBg: 'bg-error-500/20',
|
||||||
iconColor: 'text-rose-400',
|
iconColor: 'text-error-400',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
to: '/admin/campaigns',
|
to: '/admin/campaigns',
|
||||||
@@ -440,7 +447,7 @@ export default function AdminPanel() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/admin/remnawave',
|
to: '/admin/remnawave',
|
||||||
icon: <CubeTransparentIcon />,
|
icon: <RemnawaveIcon />,
|
||||||
title: t('admin.nav.remnawave'),
|
title: t('admin.nav.remnawave'),
|
||||||
description: t('admin.panel.remnawaveDesc'),
|
description: t('admin.panel.remnawaveDesc'),
|
||||||
},
|
},
|
||||||
|
|||||||
472
src/pages/AdminPaymentMethodEdit.tsx
Normal file
472
src/pages/AdminPaymentMethodEdit.tsx
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
|
||||||
|
import type { PromoGroupSimple } from '../types';
|
||||||
|
import { useBackButton } from '../platform/hooks/useBackButton';
|
||||||
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
|
|
||||||
|
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 METHOD_ICONS: Record<string, string> = {
|
||||||
|
telegram_stars: '⭐',
|
||||||
|
tribute: '🎁',
|
||||||
|
cryptobot: '🪙',
|
||||||
|
heleket: '⚡',
|
||||||
|
yookassa: '🏦',
|
||||||
|
mulenpay: '💳',
|
||||||
|
pal24: '💸',
|
||||||
|
platega: '💰',
|
||||||
|
wata: '💧',
|
||||||
|
freekassa: '💵',
|
||||||
|
cloudpayments: '☁️',
|
||||||
|
kassa_ai: '🏦',
|
||||||
|
};
|
||||||
|
|
||||||
|
const METHOD_LABELS: Record<string, string> = {
|
||||||
|
telegram_stars: 'Telegram Stars',
|
||||||
|
tribute: 'Tribute',
|
||||||
|
cryptobot: 'CryptoBot',
|
||||||
|
heleket: 'Heleket',
|
||||||
|
yookassa: 'YooKassa',
|
||||||
|
mulenpay: 'MulenPay',
|
||||||
|
pal24: 'PayPalych',
|
||||||
|
platega: 'Platega',
|
||||||
|
wata: 'WATA',
|
||||||
|
freekassa: 'Freekassa',
|
||||||
|
cloudpayments: 'CloudPayments',
|
||||||
|
kassa_ai: 'Kassa AI',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CheckIcon = () => (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SaveIcon = () => (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<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 default function AdminPaymentMethodEdit() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { methodId } = useParams<{ methodId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { capabilities } = usePlatform();
|
||||||
|
|
||||||
|
// Use native Telegram back button in Mini App
|
||||||
|
useBackButton(() => navigate('/admin/payment-methods'));
|
||||||
|
|
||||||
|
// Fetch payment methods
|
||||||
|
const { data: methods, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin-payment-methods'],
|
||||||
|
queryFn: adminPaymentMethodsApi.getAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch promo groups
|
||||||
|
const { data: promoGroups = [] } = useQuery<PromoGroupSimple[]>({
|
||||||
|
queryKey: ['admin-payment-methods-promo-groups'],
|
||||||
|
queryFn: adminPaymentMethodsApi.getPromoGroups,
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = methods?.find((m) => m.method_id === methodId);
|
||||||
|
|
||||||
|
// Local state for editing
|
||||||
|
const [isEnabled, setIsEnabled] = useState(false);
|
||||||
|
const [customName, setCustomName] = useState('');
|
||||||
|
const [subOptions, setSubOptions] = useState<Record<string, boolean>>({});
|
||||||
|
const [minAmount, setMinAmount] = useState('');
|
||||||
|
const [maxAmount, setMaxAmount] = useState('');
|
||||||
|
const [userTypeFilter, setUserTypeFilter] = useState<'all' | 'telegram' | 'email'>('all');
|
||||||
|
const [firstTopupFilter, setFirstTopupFilter] = useState<'any' | 'yes' | 'no'>('any');
|
||||||
|
const [promoGroupFilterMode, setPromoGroupFilterMode] = useState<'all' | 'selected'>('all');
|
||||||
|
const [selectedPromoGroupIds, setSelectedPromoGroupIds] = useState<number[]>([]);
|
||||||
|
|
||||||
|
// Initialize state when config loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (config) {
|
||||||
|
setIsEnabled(config.is_enabled);
|
||||||
|
setCustomName(config.display_name || '');
|
||||||
|
setSubOptions(config.sub_options || {});
|
||||||
|
setMinAmount(config.min_amount_kopeks?.toString() || '');
|
||||||
|
setMaxAmount(config.max_amount_kopeks?.toString() || '');
|
||||||
|
setUserTypeFilter(config.user_type_filter);
|
||||||
|
setFirstTopupFilter(config.first_topup_filter);
|
||||||
|
setPromoGroupFilterMode(config.promo_group_filter_mode);
|
||||||
|
setSelectedPromoGroupIds(config.allowed_promo_group_ids);
|
||||||
|
}
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
// Update method mutation
|
||||||
|
const updateMethodMutation = useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) => adminPaymentMethodsApi.update(methodId!, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-payment-methods'] });
|
||||||
|
navigate('/admin/payment-methods');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
const data: Record<string, unknown> = {
|
||||||
|
is_enabled: isEnabled,
|
||||||
|
user_type_filter: userTypeFilter,
|
||||||
|
first_topup_filter: firstTopupFilter,
|
||||||
|
promo_group_filter_mode: promoGroupFilterMode,
|
||||||
|
allowed_promo_group_ids: promoGroupFilterMode === 'selected' ? selectedPromoGroupIds : [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Display name
|
||||||
|
if (customName.trim()) {
|
||||||
|
data.display_name = customName.trim();
|
||||||
|
} else {
|
||||||
|
data.reset_display_name = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sub-options
|
||||||
|
if (config.available_sub_options) {
|
||||||
|
data.sub_options = subOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Amounts
|
||||||
|
if (minAmount.trim()) {
|
||||||
|
data.min_amount_kopeks = parseInt(minAmount, 10) || null;
|
||||||
|
} else {
|
||||||
|
data.reset_min_amount = true;
|
||||||
|
}
|
||||||
|
if (maxAmount.trim()) {
|
||||||
|
data.max_amount_kopeks = parseInt(maxAmount, 10) || null;
|
||||||
|
} else {
|
||||||
|
data.reset_max_amount = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMethodMutation.mutate(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePromoGroup = (id: number) => {
|
||||||
|
setSelectedPromoGroupIds((prev) =>
|
||||||
|
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Show back button only on web, not in Telegram Mini App */}
|
||||||
|
{!capabilities.hasBackButton && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/admin/payment-methods')}
|
||||||
|
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 />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<h1 className="text-2xl font-bold text-dark-50">
|
||||||
|
{t('admin.paymentMethods.notFound', 'Payment method not found')}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = config.display_name || config.default_display_name;
|
||||||
|
const icon = METHOD_ICONS[config.method_id] || '💳';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Show back button only on web, not in Telegram Mini App */}
|
||||||
|
{!capabilities.hasBackButton && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/admin/payment-methods')}
|
||||||
|
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 />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-dark-700/50 text-xl">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-dark-50">{displayName}</h1>
|
||||||
|
<p className="text-sm text-dark-500">
|
||||||
|
{METHOD_LABELS[config.method_id] || config.method_id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<div className="card space-y-6">
|
||||||
|
{/* Enable toggle */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.paymentMethods.methodEnabled')}
|
||||||
|
</div>
|
||||||
|
{!config.is_provider_configured && (
|
||||||
|
<div className="mt-0.5 text-xs text-warning-400">
|
||||||
|
{t('admin.paymentMethods.providerNotConfigured')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEnabled(!isEnabled)}
|
||||||
|
className={`relative h-7 w-12 rounded-full transition-colors ${
|
||||||
|
isEnabled ? 'bg-accent-500' : 'bg-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition-transform ${
|
||||||
|
isEnabled ? 'left-[calc(100%-1.625rem)]' : 'left-0.5'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Display name */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.paymentMethods.displayName')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customName}
|
||||||
|
onChange={(e) => setCustomName(e.target.value)}
|
||||||
|
placeholder={config.default_display_name}
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-dark-500">
|
||||||
|
{t('admin.paymentMethods.displayNameHint')}: {config.default_display_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sub-options */}
|
||||||
|
{config.available_sub_options && config.available_sub_options.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.paymentMethods.subOptions')}
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{config.available_sub_options.map((opt) => {
|
||||||
|
const enabled = subOptions[opt.id] !== false;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.id}
|
||||||
|
onClick={() => setSubOptions((prev) => ({ ...prev, [opt.id]: !enabled }))}
|
||||||
|
className={`flex w-full items-center justify-between rounded-xl border p-3 transition-all ${
|
||||||
|
enabled
|
||||||
|
? 'border-accent-500/30 bg-dark-700/30 text-dark-100'
|
||||||
|
: 'border-dark-800 bg-dark-900/30 text-dark-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm">{opt.name}</span>
|
||||||
|
<div
|
||||||
|
className={`flex h-5 w-5 items-center justify-center rounded ${
|
||||||
|
enabled ? 'bg-accent-500 text-white' : 'border border-dark-600 bg-dark-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{enabled && <CheckIcon />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Min/Max amounts */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.paymentMethods.minAmount')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={minAmount}
|
||||||
|
onChange={(e) => setMinAmount(e.target.value)}
|
||||||
|
placeholder={config.default_min_amount_kopeks.toString()}
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.paymentMethods.maxAmount')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={maxAmount}
|
||||||
|
onChange={(e) => setMaxAmount(e.target.value)}
|
||||||
|
placeholder={config.default_max_amount_kopeks.toString()}
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Display conditions */}
|
||||||
|
<div className="border-t border-dark-700 pt-3">
|
||||||
|
<h3 className="mb-4 text-sm font-semibold text-dark-200">
|
||||||
|
{t('admin.paymentMethods.conditions')}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* User type filter */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="mb-2 block text-sm text-dark-300">
|
||||||
|
{t('admin.paymentMethods.userTypeFilter')}
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['all', 'telegram', 'email'] as const).map((val) => (
|
||||||
|
<button
|
||||||
|
key={val}
|
||||||
|
onClick={() => setUserTypeFilter(val)}
|
||||||
|
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-all ${
|
||||||
|
userTypeFilter === val
|
||||||
|
? 'border border-accent-500/40 bg-accent-500/20 text-accent-300'
|
||||||
|
: 'border border-dark-700 bg-dark-900/50 text-dark-400 hover:border-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{val === 'all'
|
||||||
|
? t('admin.paymentMethods.userTypeAll')
|
||||||
|
: val === 'telegram'
|
||||||
|
? 'Telegram'
|
||||||
|
: 'Email'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* First topup filter */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="mb-2 block text-sm text-dark-300">
|
||||||
|
{t('admin.paymentMethods.firstTopupFilter')}
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['any', 'yes', 'no'] as const).map((val) => (
|
||||||
|
<button
|
||||||
|
key={val}
|
||||||
|
onClick={() => setFirstTopupFilter(val)}
|
||||||
|
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-all ${
|
||||||
|
firstTopupFilter === val
|
||||||
|
? 'border border-accent-500/40 bg-accent-500/20 text-accent-300'
|
||||||
|
: 'border border-dark-700 bg-dark-900/50 text-dark-400 hover:border-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{val === 'any'
|
||||||
|
? t('admin.paymentMethods.firstTopupAny')
|
||||||
|
: val === 'yes'
|
||||||
|
? t('admin.paymentMethods.firstTopupWas')
|
||||||
|
: t('admin.paymentMethods.firstTopupWasNot')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Promo groups filter */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm text-dark-300">
|
||||||
|
{t('admin.paymentMethods.promoGroupFilter')}
|
||||||
|
</label>
|
||||||
|
<div className="mb-3 flex gap-2">
|
||||||
|
{(['all', 'selected'] as const).map((val) => (
|
||||||
|
<button
|
||||||
|
key={val}
|
||||||
|
onClick={() => setPromoGroupFilterMode(val)}
|
||||||
|
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-all ${
|
||||||
|
promoGroupFilterMode === val
|
||||||
|
? 'border border-accent-500/40 bg-accent-500/20 text-accent-300'
|
||||||
|
: 'border border-dark-700 bg-dark-900/50 text-dark-400 hover:border-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{val === 'all'
|
||||||
|
? t('admin.paymentMethods.promoGroupAll')
|
||||||
|
: t('admin.paymentMethods.promoGroupSelected')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{promoGroupFilterMode === 'selected' && (
|
||||||
|
<div className="max-h-48 space-y-1.5 overflow-y-auto rounded-xl border border-dark-700/50 bg-dark-900/30 p-3">
|
||||||
|
{promoGroups.length === 0 ? (
|
||||||
|
<p className="py-2 text-center text-sm text-dark-500">
|
||||||
|
{t('admin.paymentMethods.noPromoGroups')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
promoGroups.map((group) => {
|
||||||
|
const selected = selectedPromoGroupIds.includes(group.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={group.id}
|
||||||
|
onClick={() => togglePromoGroup(group.id)}
|
||||||
|
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm transition-all ${
|
||||||
|
selected
|
||||||
|
? 'bg-accent-500/15 text-accent-300'
|
||||||
|
: 'text-dark-400 hover:bg-dark-800/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{group.name}</span>
|
||||||
|
<div
|
||||||
|
className={`flex h-4 w-4 items-center justify-center rounded ${
|
||||||
|
selected ? 'bg-accent-500 text-white' : 'border border-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selected && <CheckIcon />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={() => navigate('/admin/payment-methods')} className="btn-secondary flex-1">
|
||||||
|
{t('admin.paymentMethods.cancelButton')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={updateMethodMutation.isPending}
|
||||||
|
className="btn-primary flex flex-1 items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{updateMethodMutation.isPending ? (
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
|
) : (
|
||||||
|
<SaveIcon />
|
||||||
|
)}
|
||||||
|
{t('admin.paymentMethods.saveButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,19 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom';
|
import { useBackButton } from '../platform/hooks/useBackButton';
|
||||||
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
closestCenter,
|
|
||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
TouchSensor,
|
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
|
type DragStartEvent,
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
|
import { useTelegramDnd } from '../hooks/useTelegramDnd';
|
||||||
import {
|
import {
|
||||||
arrayMove,
|
arrayMove,
|
||||||
SortableContext,
|
SortableContext,
|
||||||
@@ -21,7 +23,7 @@ import {
|
|||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
|
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
|
||||||
import type { PaymentMethodConfig, PromoGroupSimple } from '../types';
|
import type { PaymentMethodConfig } from '../types';
|
||||||
|
|
||||||
// ============ Icons ============
|
// ============ Icons ============
|
||||||
|
|
||||||
@@ -53,12 +55,6 @@ const ChevronRightIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const CloseIcon = () => (
|
|
||||||
<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 CheckIcon = () => (
|
const CheckIcon = () => (
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
@@ -92,21 +88,6 @@ const METHOD_ICONS: Record<string, string> = {
|
|||||||
kassa_ai: '\uD83C\uDFE6',
|
kassa_ai: '\uD83C\uDFE6',
|
||||||
};
|
};
|
||||||
|
|
||||||
const METHOD_LABELS: Record<string, string> = {
|
|
||||||
telegram_stars: 'Telegram Stars',
|
|
||||||
tribute: 'Tribute',
|
|
||||||
cryptobot: 'CryptoBot',
|
|
||||||
heleket: 'Heleket',
|
|
||||||
yookassa: 'YooKassa',
|
|
||||||
mulenpay: 'MulenPay',
|
|
||||||
pal24: 'PayPalych',
|
|
||||||
platega: 'Platega',
|
|
||||||
wata: 'WATA',
|
|
||||||
freekassa: 'Freekassa',
|
|
||||||
cloudpayments: 'CloudPayments',
|
|
||||||
kassa_ai: 'Kassa AI',
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ Sortable Card ============
|
// ============ Sortable Card ============
|
||||||
|
|
||||||
interface SortableCardProps {
|
interface SortableCardProps {
|
||||||
@@ -120,11 +101,11 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
|
|||||||
id: config.method_id,
|
id: config.method_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const style = {
|
const style: React.CSSProperties = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
zIndex: isDragging ? 50 : undefined,
|
zIndex: isDragging ? 50 : undefined,
|
||||||
opacity: isDragging ? 0.85 : 1,
|
position: isDragging ? 'relative' : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const displayName = config.display_name || config.default_display_name;
|
const displayName = config.display_name || config.default_display_name;
|
||||||
@@ -159,19 +140,20 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
|
|||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
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
|
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
|
: config.is_enabled
|
||||||
? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
|
? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
|
||||||
: 'border-dark-800/50 bg-dark-900/30 opacity-60'
|
: 'border-dark-800/50 bg-dark-900/30 opacity-60'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Drag handle */}
|
{/* Drag handle */}
|
||||||
|
{/* Drag handle - larger touch target for mobile */}
|
||||||
<button
|
<button
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...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')}
|
title={t('admin.paymentMethods.dragToReorder')}
|
||||||
>
|
>
|
||||||
<GripIcon />
|
<GripIcon />
|
||||||
@@ -233,382 +215,6 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Detail Modal ============
|
|
||||||
|
|
||||||
interface DetailModalProps {
|
|
||||||
config: PaymentMethodConfig;
|
|
||||||
promoGroups: PromoGroupSimple[];
|
|
||||||
onClose: () => void;
|
|
||||||
onSave: (methodId: string, data: Record<string, unknown>) => void;
|
|
||||||
isSaving: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PaymentMethodDetailModal({
|
|
||||||
config,
|
|
||||||
promoGroups,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
isSaving,
|
|
||||||
}: DetailModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const displayName = config.display_name || config.default_display_name;
|
|
||||||
const icon = METHOD_ICONS[config.method_id] || '\uD83D\uDCB3';
|
|
||||||
|
|
||||||
// Local state for editing
|
|
||||||
const [isEnabled, setIsEnabled] = useState(config.is_enabled);
|
|
||||||
const [customName, setCustomName] = useState(config.display_name || '');
|
|
||||||
const [subOptions, setSubOptions] = useState<Record<string, boolean>>(config.sub_options || {});
|
|
||||||
const [minAmount, setMinAmount] = useState(config.min_amount_kopeks?.toString() || '');
|
|
||||||
const [maxAmount, setMaxAmount] = useState(config.max_amount_kopeks?.toString() || '');
|
|
||||||
const [userTypeFilter, setUserTypeFilter] = useState(config.user_type_filter);
|
|
||||||
const [firstTopupFilter, setFirstTopupFilter] = useState(config.first_topup_filter);
|
|
||||||
const [promoGroupFilterMode, setPromoGroupFilterMode] = useState(config.promo_group_filter_mode);
|
|
||||||
const [selectedPromoGroupIds, setSelectedPromoGroupIds] = useState<number[]>(
|
|
||||||
config.allowed_promo_group_ids,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Escape to close
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') onClose();
|
|
||||||
};
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
// Scroll lock
|
|
||||||
useEffect(() => {
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
const data: Record<string, unknown> = {
|
|
||||||
is_enabled: isEnabled,
|
|
||||||
user_type_filter: userTypeFilter,
|
|
||||||
first_topup_filter: firstTopupFilter,
|
|
||||||
promo_group_filter_mode: promoGroupFilterMode,
|
|
||||||
allowed_promo_group_ids: promoGroupFilterMode === 'selected' ? selectedPromoGroupIds : [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Display name
|
|
||||||
if (customName.trim()) {
|
|
||||||
data.display_name = customName.trim();
|
|
||||||
} else {
|
|
||||||
data.reset_display_name = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sub-options
|
|
||||||
if (config.available_sub_options) {
|
|
||||||
data.sub_options = subOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Amounts
|
|
||||||
if (minAmount.trim()) {
|
|
||||||
data.min_amount_kopeks = parseInt(minAmount, 10) || null;
|
|
||||||
} else {
|
|
||||||
data.reset_min_amount = true;
|
|
||||||
}
|
|
||||||
if (maxAmount.trim()) {
|
|
||||||
data.max_amount_kopeks = parseInt(maxAmount, 10) || null;
|
|
||||||
} else {
|
|
||||||
data.reset_max_amount = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
onSave(config.method_id, data);
|
|
||||||
};
|
|
||||||
|
|
||||||
const togglePromoGroup = (id: number) => {
|
|
||||||
setSelectedPromoGroupIds((prev) =>
|
|
||||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 flex items-start justify-center sm:items-center"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" />
|
|
||||||
<div
|
|
||||||
className="relative m-4 max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-2xl border border-dark-700 bg-dark-800 shadow-2xl"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="sticky top-0 z-10 flex items-center justify-between rounded-t-2xl border-b border-dark-700 bg-dark-800 p-5">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-dark-700/50 text-xl">
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-bold text-dark-50">{displayName}</h2>
|
|
||||||
<p className="text-xs text-dark-500">
|
|
||||||
{METHOD_LABELS[config.method_id] || config.method_id}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="rounded-lg p-2 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
|
||||||
>
|
|
||||||
<CloseIcon />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6 p-5">
|
|
||||||
{/* Enable toggle */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-dark-200">
|
|
||||||
{t('admin.paymentMethods.methodEnabled')}
|
|
||||||
</div>
|
|
||||||
{!config.is_provider_configured && (
|
|
||||||
<div className="mt-0.5 text-xs text-warning-400">
|
|
||||||
{t('admin.paymentMethods.providerNotConfigured')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsEnabled(!isEnabled)}
|
|
||||||
className={`relative h-7 w-12 rounded-full transition-colors ${
|
|
||||||
isEnabled ? 'bg-accent-500' : 'bg-dark-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition-transform ${
|
|
||||||
isEnabled ? 'left-[calc(100%-1.625rem)]' : 'left-0.5'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Display name */}
|
|
||||||
<div>
|
|
||||||
<label className="mb-2 block text-sm font-medium text-dark-200">
|
|
||||||
{t('admin.paymentMethods.displayName')}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={customName}
|
|
||||||
onChange={(e) => setCustomName(e.target.value)}
|
|
||||||
placeholder={config.default_display_name}
|
|
||||||
className="w-full rounded-xl border border-dark-700 bg-dark-900/50 px-4 py-2.5 text-dark-100 transition-colors placeholder:text-dark-500 focus:border-accent-500/50 focus:outline-none"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-dark-500">
|
|
||||||
{t('admin.paymentMethods.displayNameHint')}: {config.default_display_name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sub-options */}
|
|
||||||
{config.available_sub_options && config.available_sub_options.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<label className="mb-2 block text-sm font-medium text-dark-200">
|
|
||||||
{t('admin.paymentMethods.subOptions')}
|
|
||||||
</label>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{config.available_sub_options.map((opt) => {
|
|
||||||
const enabled = subOptions[opt.id] !== false;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={opt.id}
|
|
||||||
onClick={() => setSubOptions((prev) => ({ ...prev, [opt.id]: !enabled }))}
|
|
||||||
className={`flex w-full items-center justify-between rounded-xl border p-3 transition-all ${
|
|
||||||
enabled
|
|
||||||
? 'border-accent-500/30 bg-dark-700/30 text-dark-100'
|
|
||||||
: 'border-dark-800 bg-dark-900/30 text-dark-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="text-sm">{opt.name}</span>
|
|
||||||
<div
|
|
||||||
className={`flex h-5 w-5 items-center justify-center rounded ${
|
|
||||||
enabled
|
|
||||||
? 'bg-accent-500 text-white'
|
|
||||||
: 'border border-dark-600 bg-dark-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{enabled && <CheckIcon />}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Min/Max amounts */}
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="mb-2 block text-sm font-medium text-dark-200">
|
|
||||||
{t('admin.paymentMethods.minAmount')}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={minAmount}
|
|
||||||
onChange={(e) => setMinAmount(e.target.value)}
|
|
||||||
placeholder={config.default_min_amount_kopeks.toString()}
|
|
||||||
className="w-full rounded-xl border border-dark-700 bg-dark-900/50 px-4 py-2.5 text-dark-100 transition-colors placeholder:text-dark-500 focus:border-accent-500/50 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-2 block text-sm font-medium text-dark-200">
|
|
||||||
{t('admin.paymentMethods.maxAmount')}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={maxAmount}
|
|
||||||
onChange={(e) => setMaxAmount(e.target.value)}
|
|
||||||
placeholder={config.default_max_amount_kopeks.toString()}
|
|
||||||
className="w-full rounded-xl border border-dark-700 bg-dark-900/50 px-4 py-2.5 text-dark-100 transition-colors placeholder:text-dark-500 focus:border-accent-500/50 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Display conditions */}
|
|
||||||
<div className="border-t border-dark-700 pt-3">
|
|
||||||
<h3 className="mb-4 text-sm font-semibold text-dark-200">
|
|
||||||
{t('admin.paymentMethods.conditions')}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{/* User type filter */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="mb-2 block text-sm text-dark-300">
|
|
||||||
{t('admin.paymentMethods.userTypeFilter')}
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{(['all', 'telegram', 'email'] as const).map((val) => (
|
|
||||||
<button
|
|
||||||
key={val}
|
|
||||||
onClick={() => setUserTypeFilter(val)}
|
|
||||||
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-all ${
|
|
||||||
userTypeFilter === val
|
|
||||||
? 'border border-accent-500/40 bg-accent-500/20 text-accent-300'
|
|
||||||
: 'border border-dark-700 bg-dark-900/50 text-dark-400 hover:border-dark-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{val === 'all'
|
|
||||||
? t('admin.paymentMethods.userTypeAll')
|
|
||||||
: val === 'telegram'
|
|
||||||
? 'Telegram'
|
|
||||||
: 'Email'}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* First topup filter */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="mb-2 block text-sm text-dark-300">
|
|
||||||
{t('admin.paymentMethods.firstTopupFilter')}
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{(['any', 'yes', 'no'] as const).map((val) => (
|
|
||||||
<button
|
|
||||||
key={val}
|
|
||||||
onClick={() => setFirstTopupFilter(val)}
|
|
||||||
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-all ${
|
|
||||||
firstTopupFilter === val
|
|
||||||
? 'border border-accent-500/40 bg-accent-500/20 text-accent-300'
|
|
||||||
: 'border border-dark-700 bg-dark-900/50 text-dark-400 hover:border-dark-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{val === 'any'
|
|
||||||
? t('admin.paymentMethods.firstTopupAny')
|
|
||||||
: val === 'yes'
|
|
||||||
? t('admin.paymentMethods.firstTopupWas')
|
|
||||||
: t('admin.paymentMethods.firstTopupWasNot')}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Promo groups filter */}
|
|
||||||
<div>
|
|
||||||
<label className="mb-2 block text-sm text-dark-300">
|
|
||||||
{t('admin.paymentMethods.promoGroupFilter')}
|
|
||||||
</label>
|
|
||||||
<div className="mb-3 flex gap-2">
|
|
||||||
{(['all', 'selected'] as const).map((val) => (
|
|
||||||
<button
|
|
||||||
key={val}
|
|
||||||
onClick={() => setPromoGroupFilterMode(val)}
|
|
||||||
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-all ${
|
|
||||||
promoGroupFilterMode === val
|
|
||||||
? 'border border-accent-500/40 bg-accent-500/20 text-accent-300'
|
|
||||||
: 'border border-dark-700 bg-dark-900/50 text-dark-400 hover:border-dark-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{val === 'all'
|
|
||||||
? t('admin.paymentMethods.promoGroupAll')
|
|
||||||
: t('admin.paymentMethods.promoGroupSelected')}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{promoGroupFilterMode === 'selected' && (
|
|
||||||
<div className="max-h-48 space-y-1.5 overflow-y-auto rounded-xl border border-dark-700/50 bg-dark-900/30 p-3">
|
|
||||||
{promoGroups.length === 0 ? (
|
|
||||||
<p className="py-2 text-center text-sm text-dark-500">
|
|
||||||
{t('admin.paymentMethods.noPromoGroups')}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
promoGroups.map((group) => {
|
|
||||||
const selected = selectedPromoGroupIds.includes(group.id);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={group.id}
|
|
||||||
onClick={() => togglePromoGroup(group.id)}
|
|
||||||
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm transition-all ${
|
|
||||||
selected
|
|
||||||
? 'bg-accent-500/15 text-accent-300'
|
|
||||||
: 'text-dark-400 hover:bg-dark-800/50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span>{group.name}</span>
|
|
||||||
<div
|
|
||||||
className={`flex h-4 w-4 items-center justify-center rounded ${
|
|
||||||
selected ? 'bg-accent-500 text-white' : 'border border-dark-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{selected && <CheckIcon />}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="sticky bottom-0 flex items-center gap-3 rounded-b-2xl border-t border-dark-700 bg-dark-800 p-5">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="flex-1 rounded-xl bg-dark-700 px-4 py-2.5 font-medium text-dark-300 transition-colors hover:bg-dark-600"
|
|
||||||
>
|
|
||||||
{t('admin.paymentMethods.cancelButton')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={isSaving}
|
|
||||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-accent-500 px-4 py-2.5 font-medium text-white transition-colors hover:bg-accent-400 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isSaving ? (
|
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
|
||||||
) : (
|
|
||||||
<SaveIcon />
|
|
||||||
)}
|
|
||||||
{t('admin.paymentMethods.saveButton')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Toast ============
|
// ============ Toast ============
|
||||||
|
|
||||||
function Toast({ message, onClose }: { message: string; onClose: () => void }) {
|
function Toast({ message, onClose }: { message: string; onClose: () => void }) {
|
||||||
@@ -629,10 +235,14 @@ function Toast({ message, onClose }: { message: string; onClose: () => void }) {
|
|||||||
|
|
||||||
export default function AdminPaymentMethods() {
|
export default function AdminPaymentMethods() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { capabilities } = usePlatform();
|
||||||
|
|
||||||
|
// Use native Telegram back button in Mini App
|
||||||
|
useBackButton(() => navigate('/admin'));
|
||||||
|
|
||||||
const [methods, setMethods] = useState<PaymentMethodConfig[]>([]);
|
const [methods, setMethods] = useState<PaymentMethodConfig[]>([]);
|
||||||
const [selectedMethod, setSelectedMethod] = useState<PaymentMethodConfig | null>(null);
|
|
||||||
const [orderChanged, setOrderChanged] = useState(false);
|
const [orderChanged, setOrderChanged] = useState(false);
|
||||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -642,12 +252,6 @@ export default function AdminPaymentMethods() {
|
|||||||
queryFn: adminPaymentMethodsApi.getAll,
|
queryFn: adminPaymentMethodsApi.getAll,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch promo groups
|
|
||||||
const { data: promoGroups = [] } = useQuery({
|
|
||||||
queryKey: ['admin-payment-methods-promo-groups'],
|
|
||||||
queryFn: adminPaymentMethodsApi.getPromoGroups,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync fetched data to local state
|
// Sync fetched data to local state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fetchedMethods && !orderChanged) {
|
if (fetchedMethods && !orderChanged) {
|
||||||
@@ -668,28 +272,29 @@ export default function AdminPaymentMethods() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update method mutation
|
// DnD sensors - PointerSensor handles both mouse and touch
|
||||||
const updateMethodMutation = useMutation({
|
|
||||||
mutationFn: ({ methodId, data }: { methodId: string; data: Record<string, unknown> }) =>
|
|
||||||
adminPaymentMethodsApi.update(methodId, data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-payment-methods'] });
|
|
||||||
setSelectedMethod(null);
|
|
||||||
setToastMessage(t('admin.paymentMethods.saved'));
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
setToastMessage(t('common.error'));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// DnD sensors
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }),
|
|
||||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
// Telegram swipe behavior for drag-and-drop
|
||||||
|
const {
|
||||||
|
onDragStart: onTelegramDragStart,
|
||||||
|
onDragEnd: onTelegramDragEnd,
|
||||||
|
onDragCancel: onTelegramDragCancel,
|
||||||
|
} = useTelegramDnd();
|
||||||
|
|
||||||
|
const handleDragStart = useCallback(
|
||||||
|
(_event: DragStartEvent) => {
|
||||||
|
onTelegramDragStart();
|
||||||
|
},
|
||||||
|
[onTelegramDragStart],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
(event: DragEndEvent) => {
|
||||||
|
onTelegramDragEnd();
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
if (over && active.id !== over.id) {
|
if (over && active.id !== over.id) {
|
||||||
setMethods((prev) => {
|
setMethods((prev) => {
|
||||||
@@ -700,16 +305,18 @@ export default function AdminPaymentMethods() {
|
|||||||
});
|
});
|
||||||
setOrderChanged(true);
|
setOrderChanged(true);
|
||||||
}
|
}
|
||||||
}, []);
|
},
|
||||||
|
[onTelegramDragEnd],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragCancel = useCallback(() => {
|
||||||
|
onTelegramDragCancel();
|
||||||
|
}, [onTelegramDragCancel]);
|
||||||
|
|
||||||
const handleSaveOrder = () => {
|
const handleSaveOrder = () => {
|
||||||
saveOrderMutation.mutate(methods.map((m) => m.method_id));
|
saveOrderMutation.mutate(methods.map((m) => m.method_id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveMethod = (methodId: string, data: Record<string, unknown>) => {
|
|
||||||
updateMethodMutation.mutate({ methodId, data });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseToast = useCallback(() => setToastMessage(null), []);
|
const handleCloseToast = useCallback(() => setToastMessage(null), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -717,12 +324,15 @@ export default function AdminPaymentMethods() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link
|
{/* Show back button only on web, not in Telegram Mini App */}
|
||||||
to="/admin"
|
{!capabilities.hasBackButton && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/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"
|
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 />
|
<BackIcon />
|
||||||
</Link>
|
</button>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-dark-50">{t('admin.paymentMethods.title')}</h1>
|
<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>
|
<p className="text-sm text-dark-400">{t('admin.paymentMethods.description')}</p>
|
||||||
@@ -759,8 +369,9 @@ export default function AdminPaymentMethods() {
|
|||||||
) : methods.length > 0 ? (
|
) : methods.length > 0 ? (
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCenter}
|
onDragStart={handleDragStart}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragCancel={handleDragCancel}
|
||||||
>
|
>
|
||||||
<SortableContext
|
<SortableContext
|
||||||
items={methods.map((m) => m.method_id)}
|
items={methods.map((m) => m.method_id)}
|
||||||
@@ -771,7 +382,7 @@ export default function AdminPaymentMethods() {
|
|||||||
<SortablePaymentCard
|
<SortablePaymentCard
|
||||||
key={config.method_id}
|
key={config.method_id}
|
||||||
config={config}
|
config={config}
|
||||||
onClick={() => setSelectedMethod(config)}
|
onClick={() => navigate(`/admin/payment-methods/${config.method_id}/edit`)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -787,17 +398,6 @@ export default function AdminPaymentMethods() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detail Modal */}
|
|
||||||
{selectedMethod && (
|
|
||||||
<PaymentMethodDetailModal
|
|
||||||
config={selectedMethod}
|
|
||||||
promoGroups={promoGroups}
|
|
||||||
onClose={() => setSelectedMethod(null)}
|
|
||||||
onSave={handleSaveMethod}
|
|
||||||
isSaving={updateMethodMutation.isPending}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Toast */}
|
{/* Toast */}
|
||||||
{toastMessage && <Toast message={toastMessage} onClose={handleCloseToast} />}
|
{toastMessage && <Toast message={toastMessage} onClose={handleCloseToast} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,35 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { adminPaymentsApi } from '../api/adminPayments';
|
import { adminPaymentsApi } from '../api/adminPayments';
|
||||||
import { useCurrency } from '../hooks/useCurrency';
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
import type { PendingPayment, PaginatedResponse } from '../types';
|
import type { PendingPayment, PaginatedResponse } from '../types';
|
||||||
|
import { useBackButton } from '../platform/hooks/useBackButton';
|
||||||
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
|
|
||||||
|
// BackIcon
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
export default function AdminPayments() {
|
export default function AdminPayments() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { formatAmount, currencySymbol } = useCurrency();
|
const { formatAmount, currencySymbol } = useCurrency();
|
||||||
|
const { capabilities } = usePlatform();
|
||||||
|
|
||||||
|
// Use native Telegram back button in Mini App
|
||||||
|
useBackButton(() => navigate('/admin'));
|
||||||
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [methodFilter, setMethodFilter] = useState<string>('');
|
const [methodFilter, setMethodFilter] = useState<string>('');
|
||||||
@@ -68,20 +88,15 @@ export default function AdminPayments() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link
|
{/* Show back button only on web, not in Telegram Mini App */}
|
||||||
to="/admin"
|
{!capabilities.hasBackButton && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/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"
|
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
|
<BackIcon />
|
||||||
className="h-5 w-5 text-dark-400"
|
</button>
|
||||||
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>
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-dark-50">{t('admin.payments.title')}</h1>
|
<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>
|
<p className="text-sm text-dark-400">{t('admin.payments.description')}</p>
|
||||||
|
|||||||
412
src/pages/AdminPromoGroupCreate.tsx
Normal file
412
src/pages/AdminPromoGroupCreate.tsx
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
promocodesApi,
|
||||||
|
PromoGroup,
|
||||||
|
PromoGroupCreateRequest,
|
||||||
|
PromoGroupUpdateRequest,
|
||||||
|
} from '../api/promocodes';
|
||||||
|
import { AdminBackButton } from '../components/admin';
|
||||||
|
import { useBackButton } from '../platform/hooks/useBackButton';
|
||||||
|
|
||||||
|
// Icons
|
||||||
|
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 TrashIcon = () => (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RefreshIcon = () => (
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4 animate-spin"
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface PeriodDiscount {
|
||||||
|
days: number;
|
||||||
|
percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPromoGroupCreate() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const isEdit = !!id;
|
||||||
|
|
||||||
|
useBackButton(() => navigate('/admin/promo-groups'));
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [serverDiscount, setServerDiscount] = useState<number | ''>(0);
|
||||||
|
const [trafficDiscount, setTrafficDiscount] = useState<number | ''>(0);
|
||||||
|
const [deviceDiscount, setDeviceDiscount] = useState<number | ''>(0);
|
||||||
|
const [applyToAddons, setApplyToAddons] = useState(true);
|
||||||
|
const [autoAssignSpent, setAutoAssignSpent] = useState<number | ''>(0);
|
||||||
|
const [periodDiscounts, setPeriodDiscounts] = useState<PeriodDiscount[]>([]);
|
||||||
|
|
||||||
|
// Fetch promo group for editing
|
||||||
|
const { isLoading: isLoadingGroup } = useQuery({
|
||||||
|
queryKey: ['admin-promo-group', id],
|
||||||
|
queryFn: () => promocodesApi.getPromoGroup(Number(id)),
|
||||||
|
enabled: isEdit,
|
||||||
|
staleTime: 0,
|
||||||
|
gcTime: 0,
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
select: useCallback((data: PromoGroup) => {
|
||||||
|
setName(data.name);
|
||||||
|
setServerDiscount(data.server_discount_percent || 0);
|
||||||
|
setTrafficDiscount(data.traffic_discount_percent || 0);
|
||||||
|
setDeviceDiscount(data.device_discount_percent || 0);
|
||||||
|
setApplyToAddons(data.apply_discounts_to_addons ?? true);
|
||||||
|
setAutoAssignSpent(
|
||||||
|
data.auto_assign_total_spent_kopeks ? data.auto_assign_total_spent_kopeks / 100 : 0,
|
||||||
|
);
|
||||||
|
if (data.period_discounts && typeof data.period_discounts === 'object') {
|
||||||
|
setPeriodDiscounts(
|
||||||
|
Object.entries(data.period_discounts).map(([days, percent]) => ({
|
||||||
|
days: parseInt(days),
|
||||||
|
percent: typeof percent === 'number' ? percent : 0,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}, []),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create mutation
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: promocodesApi.createPromoGroup,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-promo-groups'] });
|
||||||
|
navigate('/admin/promo-groups');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update mutation
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: PromoGroupUpdateRequest }) =>
|
||||||
|
promocodesApi.updatePromoGroup(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-promo-groups'] });
|
||||||
|
navigate('/admin/promo-groups');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const addPeriodDiscount = () => {
|
||||||
|
setPeriodDiscounts([...periodDiscounts, { days: 30, percent: 0 }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePeriodDiscount = (index: number) => {
|
||||||
|
setPeriodDiscounts(periodDiscounts.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePeriodDiscount = (index: number, field: 'days' | 'percent', value: number) => {
|
||||||
|
const updated = [...periodDiscounts];
|
||||||
|
updated[index][field] = value;
|
||||||
|
setPeriodDiscounts(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
// Convert periodDiscounts array to Record<number, number>
|
||||||
|
const periodDiscountsRecord: Record<number, number> = {};
|
||||||
|
periodDiscounts.forEach((pd) => {
|
||||||
|
if (pd.days > 0 && pd.percent > 0) {
|
||||||
|
periodDiscountsRecord[pd.days] = pd.percent;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const serverVal = serverDiscount === '' ? 0 : serverDiscount;
|
||||||
|
const trafficVal = trafficDiscount === '' ? 0 : trafficDiscount;
|
||||||
|
const deviceVal = deviceDiscount === '' ? 0 : deviceDiscount;
|
||||||
|
const autoAssignVal = autoAssignSpent === '' ? 0 : autoAssignSpent;
|
||||||
|
|
||||||
|
const data: PromoGroupCreateRequest | PromoGroupUpdateRequest = {
|
||||||
|
name,
|
||||||
|
server_discount_percent: serverVal,
|
||||||
|
traffic_discount_percent: trafficVal,
|
||||||
|
device_discount_percent: deviceVal,
|
||||||
|
period_discounts:
|
||||||
|
Object.keys(periodDiscountsRecord).length > 0 ? periodDiscountsRecord : undefined,
|
||||||
|
apply_discounts_to_addons: applyToAddons,
|
||||||
|
auto_assign_total_spent_kopeks: autoAssignVal > 0 ? Math.round(autoAssignVal * 100) : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit) {
|
||||||
|
updateMutation.mutate({ id: Number(id), data });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(data as PromoGroupCreateRequest);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLoading = createMutation.isPending || updateMutation.isPending;
|
||||||
|
const isValid = name.trim().length > 0;
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isEdit && isLoadingGroup) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<AdminBackButton to="/admin/promo-groups" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-dark-100">
|
||||||
|
{isEdit ? t('admin.promoGroups.editTitle') : t('admin.promoGroups.createTitle')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-dark-400">{t('admin.promoGroups.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<div className="card space-y-4">
|
||||||
|
{/* Name */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.promoGroups.form.name')}
|
||||||
|
<span className="text-error-400">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder={t('admin.promoGroups.form.namePlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Discounts */}
|
||||||
|
<div className="space-y-3 rounded-lg bg-dark-700/50 p-4">
|
||||||
|
<h4 className="mb-3 text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.promoGroups.form.categoryDiscounts')}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="w-32 text-sm text-dark-400">{t('admin.promoGroups.servers')}:</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={serverDiscount}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
if (val === '') {
|
||||||
|
setServerDiscount('');
|
||||||
|
} else {
|
||||||
|
setServerDiscount(Math.min(100, Math.max(0, parseInt(val) || 0)));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="input w-20"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
<span className="text-dark-400">%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="w-32 text-sm text-dark-400">{t('admin.promoGroups.traffic')}:</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={trafficDiscount}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
if (val === '') {
|
||||||
|
setTrafficDiscount('');
|
||||||
|
} else {
|
||||||
|
setTrafficDiscount(Math.min(100, Math.max(0, parseInt(val) || 0)));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="input w-20"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
<span className="text-dark-400">%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="w-32 text-sm text-dark-400">{t('admin.promoGroups.devices')}:</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={deviceDiscount}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
if (val === '') {
|
||||||
|
setDeviceDiscount('');
|
||||||
|
} else {
|
||||||
|
setDeviceDiscount(Math.min(100, Math.max(0, parseInt(val) || 0)));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="input w-20"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
<span className="text-dark-400">%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Period Discounts */}
|
||||||
|
<div className="space-y-3 rounded-lg bg-dark-700/50 p-4">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.promoGroups.form.periodDiscounts')}
|
||||||
|
</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addPeriodDiscount}
|
||||||
|
className="flex items-center gap-1 rounded bg-accent-500/20 px-2 py-1 text-xs text-accent-400 transition-colors hover:bg-accent-500/30"
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
{t('admin.promoGroups.form.add')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mb-3 text-xs text-dark-500">{t('admin.promoGroups.form.periodHint')}</p>
|
||||||
|
|
||||||
|
{periodDiscounts.length === 0 ? (
|
||||||
|
<p className="py-2 text-center text-sm text-dark-500">
|
||||||
|
{t('admin.promoGroups.form.noPeriods')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{periodDiscounts.map((pd, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={pd.days}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePeriodDiscount(
|
||||||
|
index,
|
||||||
|
'days',
|
||||||
|
Math.max(1, parseInt(e.target.value) || 1),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="input w-20"
|
||||||
|
min={1}
|
||||||
|
placeholder={t('admin.promoGroups.form.daysPlaceholder')}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-dark-400">{t('admin.promoGroups.form.arrow')}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={pd.percent}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePeriodDiscount(
|
||||||
|
index,
|
||||||
|
'percent',
|
||||||
|
Math.min(100, Math.max(0, parseInt(e.target.value) || 0)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="input w-20"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
placeholder="%"
|
||||||
|
/>
|
||||||
|
<span className="text-dark-400">%</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removePeriodDiscount(index)}
|
||||||
|
className="p-1 text-dark-400 transition-colors hover:text-error-400"
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-assign */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.promoGroups.form.autoAssign')}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={autoAssignSpent}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
if (val === '') {
|
||||||
|
setAutoAssignSpent('');
|
||||||
|
} else {
|
||||||
|
setAutoAssignSpent(Math.max(0, parseFloat(val) || 0));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="input w-32"
|
||||||
|
min={0}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
<span className="text-dark-400">{t('admin.promoGroups.form.rub')}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-dark-500">{t('admin.promoGroups.form.autoAssignHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Apply to addons */}
|
||||||
|
<label className="flex cursor-pointer items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setApplyToAddons(!applyToAddons)}
|
||||||
|
className={`relative h-6 w-10 rounded-full transition-colors ${
|
||||||
|
applyToAddons ? 'bg-accent-500' : 'bg-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||||
|
applyToAddons ? 'left-5' : 'left-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-dark-200">{t('admin.promoGroups.form.applyToAddons')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/admin/promo-groups')}
|
||||||
|
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
|
||||||
|
>
|
||||||
|
{t('admin.promoGroups.form.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!isValid || isLoading}
|
||||||
|
className="btn-primary flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{isLoading && <RefreshIcon />}
|
||||||
|
{isLoading ? t('admin.promoGroups.form.saving') : t('admin.promoGroups.form.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user