mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +00:00
66
src/App.tsx
66
src/App.tsx
@@ -9,6 +9,7 @@ import {
|
|||||||
ChannelSubscriptionScreen,
|
ChannelSubscriptionScreen,
|
||||||
BlacklistedScreen,
|
BlacklistedScreen,
|
||||||
} from './components/blocking';
|
} from './components/blocking';
|
||||||
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
import { PermissionRoute } from '@/components/auth/PermissionRoute';
|
import { PermissionRoute } from '@/components/auth/PermissionRoute';
|
||||||
import { saveReturnUrl } from './utils/token';
|
import { saveReturnUrl } from './utils/token';
|
||||||
import { useAnalyticsCounters } from './hooks/useAnalyticsCounters';
|
import { useAnalyticsCounters } from './hooks/useAnalyticsCounters';
|
||||||
@@ -37,6 +38,9 @@ const Info = lazy(() => import('./pages/Info'));
|
|||||||
const Wheel = lazy(() => import('./pages/Wheel'));
|
const Wheel = lazy(() => import('./pages/Wheel'));
|
||||||
const Connection = lazy(() => import('./pages/Connection'));
|
const Connection = lazy(() => import('./pages/Connection'));
|
||||||
const ConnectionQR = lazy(() => import('./pages/ConnectionQR'));
|
const ConnectionQR = lazy(() => import('./pages/ConnectionQR'));
|
||||||
|
const QuickPurchase = lazy(() => import('./pages/QuickPurchase'));
|
||||||
|
const PurchaseSuccess = lazy(() => import('./pages/PurchaseSuccess'));
|
||||||
|
const AutoLogin = lazy(() => import('./pages/AutoLogin'));
|
||||||
const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect'));
|
const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect'));
|
||||||
const TopUpAmount = lazy(() => import('./pages/TopUpAmount'));
|
const TopUpAmount = lazy(() => import('./pages/TopUpAmount'));
|
||||||
const ConnectedAccounts = lazy(() => import('./pages/ConnectedAccounts'));
|
const ConnectedAccounts = lazy(() => import('./pages/ConnectedAccounts'));
|
||||||
@@ -104,6 +108,8 @@ const AdminRoleAssign = lazy(() => import('./pages/AdminRoleAssign'));
|
|||||||
const AdminPolicies = lazy(() => import('./pages/AdminPolicies'));
|
const AdminPolicies = lazy(() => import('./pages/AdminPolicies'));
|
||||||
const AdminPolicyEdit = lazy(() => import('./pages/AdminPolicyEdit'));
|
const AdminPolicyEdit = lazy(() => import('./pages/AdminPolicyEdit'));
|
||||||
const AdminAuditLog = lazy(() => import('./pages/AdminAuditLog'));
|
const AdminAuditLog = lazy(() => import('./pages/AdminAuditLog'));
|
||||||
|
const AdminLandings = lazy(() => import('./pages/AdminLandings'));
|
||||||
|
const AdminLandingEditor = lazy(() => import('./pages/AdminLandingEditor'));
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
@@ -194,6 +200,36 @@ function App() {
|
|||||||
</LazyPage>
|
</LazyPage>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/buy/success/:token"
|
||||||
|
element={
|
||||||
|
<ErrorBoundary level="app">
|
||||||
|
<LazyPage>
|
||||||
|
<PurchaseSuccess />
|
||||||
|
</LazyPage>
|
||||||
|
</ErrorBoundary>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/buy/:slug"
|
||||||
|
element={
|
||||||
|
<ErrorBoundary level="app">
|
||||||
|
<LazyPage>
|
||||||
|
<QuickPurchase />
|
||||||
|
</LazyPage>
|
||||||
|
</ErrorBoundary>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/auto-login"
|
||||||
|
element={
|
||||||
|
<ErrorBoundary level="app">
|
||||||
|
<LazyPage>
|
||||||
|
<AutoLogin />
|
||||||
|
</LazyPage>
|
||||||
|
</ErrorBoundary>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Protected routes */}
|
{/* Protected routes */}
|
||||||
<Route
|
<Route
|
||||||
@@ -476,6 +512,36 @@ function App() {
|
|||||||
</PermissionRoute>
|
</PermissionRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/landings"
|
||||||
|
element={
|
||||||
|
<PermissionRoute permission="landings:read">
|
||||||
|
<LazyPage>
|
||||||
|
<AdminLandings />
|
||||||
|
</LazyPage>
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/landings/create"
|
||||||
|
element={
|
||||||
|
<PermissionRoute permission="landings:create">
|
||||||
|
<LazyPage>
|
||||||
|
<AdminLandingEditor />
|
||||||
|
</LazyPage>
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/landings/:id/edit"
|
||||||
|
element={
|
||||||
|
<PermissionRoute permission="landings:edit">
|
||||||
|
<LazyPage>
|
||||||
|
<AdminLandingEditor />
|
||||||
|
</LazyPage>
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/servers"
|
path="/admin/servers"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -49,6 +49,20 @@ export const authApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Telegram OIDC authentication (popup flow with id_token)
|
||||||
|
loginTelegramOIDC: async (
|
||||||
|
idToken: string,
|
||||||
|
campaignSlug?: string | null,
|
||||||
|
referralCode?: string | null,
|
||||||
|
): Promise<AuthResponse> => {
|
||||||
|
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/oidc', {
|
||||||
|
id_token: idToken,
|
||||||
|
campaign_slug: campaignSlug || undefined,
|
||||||
|
referral_code: referralCode || undefined,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Email login
|
// Email login
|
||||||
loginEmail: async (
|
loginEmail: async (
|
||||||
email: string,
|
email: string,
|
||||||
@@ -280,6 +294,12 @@ export const authApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Auto-login from guest purchase success page
|
||||||
|
autoLogin: async (token: string): Promise<AuthResponse> => {
|
||||||
|
const response = await apiClient.post<AuthResponse>('/cabinet/auth/login/auto', { token });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Account merge (no JWT required)
|
// Account merge (no JWT required)
|
||||||
getMergePreview: async (mergeToken: string): Promise<MergePreviewResponse> => {
|
getMergePreview: async (mergeToken: string): Promise<MergePreviewResponse> => {
|
||||||
const response = await apiClient.get<MergePreviewResponse>(
|
const response = await apiClient.get<MergePreviewResponse>(
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ export interface EmailAuthEnabled {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TelegramWidgetConfig {
|
||||||
|
bot_username: string;
|
||||||
|
size: 'large' | 'medium' | 'small';
|
||||||
|
radius: number;
|
||||||
|
userpic: boolean;
|
||||||
|
request_access: boolean;
|
||||||
|
oidc_enabled: boolean;
|
||||||
|
oidc_client_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AnalyticsCounters {
|
export interface AnalyticsCounters {
|
||||||
yandex_metrika_id: string;
|
yandex_metrika_id: string;
|
||||||
google_ads_id: string;
|
google_ads_id: string;
|
||||||
@@ -251,6 +261,26 @@ export const brandingApi = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Get Telegram widget config (public, no auth required)
|
||||||
|
getTelegramWidgetConfig: async (): Promise<TelegramWidgetConfig> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<TelegramWidgetConfig>(
|
||||||
|
'/cabinet/branding/telegram-widget',
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
bot_username: import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '',
|
||||||
|
size: 'large',
|
||||||
|
radius: 8,
|
||||||
|
userpic: true,
|
||||||
|
request_access: true,
|
||||||
|
oidc_enabled: false,
|
||||||
|
oidc_client_id: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Update analytics counters (admin only)
|
// Update analytics counters (admin only)
|
||||||
updateAnalyticsCounters: async (data: Partial<AnalyticsCounters>): Promise<AnalyticsCounters> => {
|
updateAnalyticsCounters: async (data: Partial<AnalyticsCounters>): Promise<AnalyticsCounters> => {
|
||||||
const response = await apiClient.patch<AnalyticsCounters>('/cabinet/branding/analytics', data);
|
const response = await apiClient.patch<AnalyticsCounters>('/cabinet/branding/analytics', data);
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ const AUTH_ENDPOINTS = [
|
|||||||
'/cabinet/auth/oauth/',
|
'/cabinet/auth/oauth/',
|
||||||
'/cabinet/auth/merge/',
|
'/cabinet/auth/merge/',
|
||||||
'/cabinet/auth/account/link/server-complete',
|
'/cabinet/auth/account/link/server-complete',
|
||||||
|
'/cabinet/landing/',
|
||||||
];
|
];
|
||||||
|
|
||||||
function isAuthEndpoint(url: string | undefined): boolean {
|
function isAuthEndpoint(url: string | undefined): boolean {
|
||||||
|
|||||||
315
src/api/landings.ts
Normal file
315
src/api/landings.ts
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Public types
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface LandingFeature {
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LandingTariffPeriod {
|
||||||
|
days: number;
|
||||||
|
label: string;
|
||||||
|
price_kopeks: number;
|
||||||
|
price_label: string;
|
||||||
|
original_price_kopeks: number | null;
|
||||||
|
original_price_label: string | null;
|
||||||
|
discount_percent: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LandingTariff {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
traffic_limit_gb: number;
|
||||||
|
device_limit: number;
|
||||||
|
tier_level: number;
|
||||||
|
periods: LandingTariffPeriod[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LandingPaymentMethodSubOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payment method as returned by the public landing config API */
|
||||||
|
export interface LandingPaymentMethod {
|
||||||
|
method_id: string;
|
||||||
|
display_name: string;
|
||||||
|
description: string | null;
|
||||||
|
icon_url: string | null;
|
||||||
|
sort_order: number;
|
||||||
|
min_amount_kopeks: number | null;
|
||||||
|
max_amount_kopeks: number | null;
|
||||||
|
currency: string | null;
|
||||||
|
sub_options: LandingPaymentMethodSubOption[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payment method as stored/returned by the admin landing API (sub_options is a dict) */
|
||||||
|
export interface AdminLandingPaymentMethod {
|
||||||
|
method_id: string;
|
||||||
|
display_name: string;
|
||||||
|
description: string | null;
|
||||||
|
icon_url: string | null;
|
||||||
|
sort_order: number;
|
||||||
|
min_amount_kopeks: number | null;
|
||||||
|
max_amount_kopeks: number | null;
|
||||||
|
currency: string | null;
|
||||||
|
return_url: string | null;
|
||||||
|
sub_options: Record<string, boolean> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Editable fields on a payment method in the landing editor */
|
||||||
|
export type EditableMethodField =
|
||||||
|
| 'display_name'
|
||||||
|
| 'description'
|
||||||
|
| 'icon_url'
|
||||||
|
| 'min_amount_kopeks'
|
||||||
|
| 'max_amount_kopeks'
|
||||||
|
| 'currency'
|
||||||
|
| 'return_url';
|
||||||
|
|
||||||
|
export interface LandingDiscountInfo {
|
||||||
|
percent: number;
|
||||||
|
ends_at: string; // ISO datetime
|
||||||
|
badge_text: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LandingConfig {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string | null;
|
||||||
|
features: LandingFeature[];
|
||||||
|
footer_text: string | null;
|
||||||
|
tariffs: LandingTariff[];
|
||||||
|
payment_methods: LandingPaymentMethod[];
|
||||||
|
gift_enabled: boolean;
|
||||||
|
custom_css: string | null;
|
||||||
|
meta_title: string | null;
|
||||||
|
meta_description: string | null;
|
||||||
|
discount: LandingDiscountInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseRequest {
|
||||||
|
tariff_id: number;
|
||||||
|
period_days: number;
|
||||||
|
contact_type: 'email' | 'telegram';
|
||||||
|
contact_value: string;
|
||||||
|
payment_method: string;
|
||||||
|
is_gift: boolean;
|
||||||
|
gift_recipient_type?: 'email' | 'telegram';
|
||||||
|
gift_recipient_value?: string;
|
||||||
|
gift_message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseResponse {
|
||||||
|
purchase_token: string;
|
||||||
|
payment_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseStatus {
|
||||||
|
status: 'pending' | 'paid' | 'delivered' | 'pending_activation' | 'failed' | 'expired';
|
||||||
|
subscription_url: string | null;
|
||||||
|
subscription_crypto_link: string | null;
|
||||||
|
is_gift: boolean;
|
||||||
|
contact_value: string | null;
|
||||||
|
recipient_contact_value: string | null;
|
||||||
|
period_days: number | null;
|
||||||
|
tariff_name: string | null;
|
||||||
|
gift_message: string | null;
|
||||||
|
contact_type: 'email' | 'telegram' | null;
|
||||||
|
cabinet_email: string | null;
|
||||||
|
cabinet_password: string | null;
|
||||||
|
auto_login_token: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Locale helpers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** Locale dict for multi-language text fields (admin API) */
|
||||||
|
export type LocaleDict = Record<string, string>;
|
||||||
|
|
||||||
|
/** Supported locales for the admin editor */
|
||||||
|
export const SUPPORTED_LOCALES = ['ru', 'en', 'zh', 'fa'] as const;
|
||||||
|
export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
|
||||||
|
|
||||||
|
export const LOCALE_META: Record<SupportedLocale, { flag: string; name: string; rtl: boolean }> = {
|
||||||
|
ru: { flag: '\u{1F1F7}\u{1F1FA}', name: 'RU', rtl: false },
|
||||||
|
en: { flag: '\u{1F1EC}\u{1F1E7}', name: 'EN', rtl: false },
|
||||||
|
zh: { flag: '\u{1F1E8}\u{1F1F3}', name: 'ZH', rtl: false },
|
||||||
|
fa: { flag: '\u{1F1EE}\u{1F1F7}', name: 'FA', rtl: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Admin types
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** Admin feature type with localized title/description */
|
||||||
|
export interface AdminLandingFeature {
|
||||||
|
icon: string;
|
||||||
|
title: LocaleDict;
|
||||||
|
description: LocaleDict;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LandingListItem {
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
title: LocaleDict;
|
||||||
|
is_active: boolean;
|
||||||
|
display_order: number;
|
||||||
|
gift_enabled: boolean;
|
||||||
|
tariff_count: number;
|
||||||
|
method_count: number;
|
||||||
|
purchase_stats: {
|
||||||
|
total: number;
|
||||||
|
pending: number;
|
||||||
|
paid: number;
|
||||||
|
delivered: number;
|
||||||
|
pending_activation: number;
|
||||||
|
failed: number;
|
||||||
|
expired: number;
|
||||||
|
};
|
||||||
|
created_at: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
has_active_discount: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LandingDetail {
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
title: LocaleDict;
|
||||||
|
subtitle: LocaleDict | null;
|
||||||
|
is_active: boolean;
|
||||||
|
features: AdminLandingFeature[];
|
||||||
|
footer_text: LocaleDict | null;
|
||||||
|
allowed_tariff_ids: number[];
|
||||||
|
allowed_periods: Record<string, number[]>;
|
||||||
|
payment_methods: AdminLandingPaymentMethod[];
|
||||||
|
gift_enabled: boolean;
|
||||||
|
custom_css: string | null;
|
||||||
|
meta_title: LocaleDict | null;
|
||||||
|
meta_description: LocaleDict | null;
|
||||||
|
display_order: number;
|
||||||
|
created_at: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
discount_percent: number | null;
|
||||||
|
discount_overrides: Record<string, number> | null;
|
||||||
|
discount_starts_at: string | null;
|
||||||
|
discount_ends_at: string | null;
|
||||||
|
discount_badge_text: LocaleDict | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LandingCreateRequest {
|
||||||
|
slug: string;
|
||||||
|
title: LocaleDict;
|
||||||
|
subtitle?: LocaleDict;
|
||||||
|
is_active?: boolean;
|
||||||
|
features?: AdminLandingFeature[];
|
||||||
|
footer_text?: LocaleDict;
|
||||||
|
allowed_tariff_ids?: number[];
|
||||||
|
allowed_periods?: Record<string, number[]>;
|
||||||
|
payment_methods?: AdminLandingPaymentMethod[];
|
||||||
|
gift_enabled?: boolean;
|
||||||
|
custom_css?: string;
|
||||||
|
meta_title?: LocaleDict;
|
||||||
|
meta_description?: LocaleDict;
|
||||||
|
discount_percent?: number | null;
|
||||||
|
discount_overrides?: Record<string, number> | null;
|
||||||
|
discount_starts_at?: string | null;
|
||||||
|
discount_ends_at?: string | null;
|
||||||
|
discount_badge_text?: LocaleDict | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LandingUpdateRequest = Partial<LandingCreateRequest>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a value that might be a plain string (old API) or a LocaleDict.
|
||||||
|
* If it's a string, wraps it as `{ ru: value }`.
|
||||||
|
* If null/undefined, returns the fallback.
|
||||||
|
*/
|
||||||
|
/** Extract best display string from a LocaleDict: ru → en → first available → '' */
|
||||||
|
export function resolveLocaleDisplay(dict: LocaleDict | string | null | undefined): string {
|
||||||
|
if (!dict) return '';
|
||||||
|
if (typeof dict === 'string') return dict;
|
||||||
|
return dict.ru || dict.en || Object.values(dict).find((v) => v?.trim()) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toLocaleDict(
|
||||||
|
value: string | LocaleDict | null | undefined,
|
||||||
|
fallback: LocaleDict = {},
|
||||||
|
): LocaleDict {
|
||||||
|
if (value === null || value === undefined) return fallback;
|
||||||
|
if (typeof value === 'string') return value ? { ru: value } : fallback;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Public API
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export const landingApi = {
|
||||||
|
getConfig: async (slug: string, lang?: string): Promise<LandingConfig> => {
|
||||||
|
const params = lang ? `?lang=${lang}` : '';
|
||||||
|
const response = await apiClient.get(`/cabinet/landing/${slug}${params}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createPurchase: async (slug: string, data: PurchaseRequest): Promise<PurchaseResponse> => {
|
||||||
|
const response = await apiClient.post(`/cabinet/landing/${slug}/purchase`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getPurchaseStatus: async (token: string): Promise<PurchaseStatus> => {
|
||||||
|
const response = await apiClient.get(`/cabinet/landing/purchase/${token}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
activatePurchase: async (token: string): Promise<PurchaseStatus> => {
|
||||||
|
const response = await apiClient.post(`/cabinet/landing/activate/${token}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Admin API
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export const adminLandingsApi = {
|
||||||
|
list: async (): Promise<LandingListItem[]> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/landings');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
get: async (id: number): Promise<LandingDetail> => {
|
||||||
|
const response = await apiClient.get(`/cabinet/admin/landings/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data: LandingCreateRequest): Promise<LandingDetail> => {
|
||||||
|
const response = await apiClient.post('/cabinet/admin/landings', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: number, data: LandingUpdateRequest): Promise<LandingDetail> => {
|
||||||
|
const response = await apiClient.put(`/cabinet/admin/landings/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: number): Promise<{ success: boolean }> => {
|
||||||
|
const response = await apiClient.delete(`/cabinet/admin/landings/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
toggle: async (id: number): Promise<LandingDetail> => {
|
||||||
|
const response = await apiClient.post(`/cabinet/admin/landings/${id}/toggle`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
reorder: async (landingIds: number[]): Promise<void> => {
|
||||||
|
await apiClient.put('/cabinet/admin/landings/order', { landing_ids: landingIds });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -49,6 +49,16 @@ export interface UserRoleAssignment {
|
|||||||
user_telegram_id: number | null;
|
user_telegram_id: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminWithRoles {
|
||||||
|
user_id: number;
|
||||||
|
telegram_id: number | null;
|
||||||
|
username: string | null;
|
||||||
|
first_name: string | null;
|
||||||
|
last_name: string | null;
|
||||||
|
email: string | null;
|
||||||
|
role_names: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface AssignRolePayload {
|
export interface AssignRolePayload {
|
||||||
user_id: number;
|
user_id: number;
|
||||||
role_id: number;
|
role_id: number;
|
||||||
@@ -167,6 +177,13 @@ export const rbacApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- RBAC Users ---
|
||||||
|
|
||||||
|
getRbacUsers: async (): Promise<AdminWithRoles[]> => {
|
||||||
|
const response = await apiClient.get<AdminWithRoles[]>(`${BASE}/users`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
// --- Role Users ---
|
// --- Role Users ---
|
||||||
|
|
||||||
getRoleUsers: async (roleId: number): Promise<UserRoleAssignment[]> => {
|
getRoleUsers: async (roleId: number): Promise<UserRoleAssignment[]> => {
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ export interface TariffDetail {
|
|||||||
daily_price_kopeks: number;
|
daily_price_kopeks: number;
|
||||||
// Режим сброса трафика
|
// Режим сброса трафика
|
||||||
traffic_reset_mode: string | null; // 'DAY', 'WEEK', 'MONTH', 'NO_RESET', null = глобальная настройка
|
traffic_reset_mode: string | null; // 'DAY', 'WEEK', 'MONTH', 'NO_RESET', null = глобальная настройка
|
||||||
|
// Внешний сквад RemnaWave
|
||||||
|
external_squad_uuid: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
}
|
}
|
||||||
@@ -121,6 +123,14 @@ export interface TariffCreateRequest {
|
|||||||
daily_price_kopeks?: number;
|
daily_price_kopeks?: number;
|
||||||
// Режим сброса трафика
|
// Режим сброса трафика
|
||||||
traffic_reset_mode?: string | null;
|
traffic_reset_mode?: string | null;
|
||||||
|
// Внешний сквад RemnaWave
|
||||||
|
external_squad_uuid?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalSquadInfo {
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
members_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TariffUpdateRequest {
|
export interface TariffUpdateRequest {
|
||||||
@@ -156,6 +166,8 @@ export interface TariffUpdateRequest {
|
|||||||
daily_price_kopeks?: number;
|
daily_price_kopeks?: number;
|
||||||
// Режим сброса трафика
|
// Режим сброса трафика
|
||||||
traffic_reset_mode?: string | null;
|
traffic_reset_mode?: string | null;
|
||||||
|
// Внешний сквад RemnaWave
|
||||||
|
external_squad_uuid?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TariffToggleResponse {
|
export interface TariffToggleResponse {
|
||||||
@@ -249,4 +261,10 @@ export const tariffsApi = {
|
|||||||
const response = await apiClient.get('/cabinet/admin/payment-methods/promo-groups');
|
const response = await apiClient.get('/cabinet/admin/payment-methods/promo-groups');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Get available external squads from RemnaWave
|
||||||
|
getAvailableExternalSquads: async (): Promise<ExternalSquadInfo[]> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/tariffs/available-external-squads');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,42 +1,141 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { brandingApi, type TelegramWidgetConfig } from '../api/branding';
|
||||||
|
import { useAuthStore } from '../store/auth';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
interface TelegramLoginButtonProps {
|
interface TelegramLoginButtonProps {
|
||||||
botUsername: string;
|
|
||||||
referralCode?: string;
|
referralCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TelegramLoginButton({
|
export default function TelegramLoginButton({ referralCode }: TelegramLoginButtonProps) {
|
||||||
botUsername,
|
|
||||||
referralCode,
|
|
||||||
}: TelegramLoginButtonProps) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [oidcLoading, setOidcLoading] = useState(false);
|
||||||
|
const [oidcError, setOidcError] = useState('');
|
||||||
|
const [scriptLoaded, setScriptLoaded] = useState(false);
|
||||||
|
const loginWithTelegramOIDC = useAuthStore((s) => s.loginWithTelegramOIDC);
|
||||||
|
|
||||||
|
const { data: widgetConfig } = useQuery<TelegramWidgetConfig>({
|
||||||
|
queryKey: ['telegram-widget-config'],
|
||||||
|
queryFn: brandingApi.getTelegramWidgetConfig,
|
||||||
|
staleTime: 60000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const botUsername =
|
||||||
|
widgetConfig?.bot_username || import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
|
||||||
|
const isOIDC = Boolean(widgetConfig?.oidc_enabled && widgetConfig?.oidc_client_id);
|
||||||
|
|
||||||
|
// OIDC callback handler (ref pattern to avoid stale closures and unnecessary re-inits)
|
||||||
|
const handleOIDCCallbackRef =
|
||||||
|
useRef<(data: { id_token?: string; error?: string }) => void>(undefined);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
// Load widget script
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current || !botUsername) return;
|
mountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Clear previous widget using safe DOM API
|
handleOIDCCallbackRef.current = async (data: { id_token?: string; error?: string }) => {
|
||||||
while (containerRef.current.firstChild) {
|
if (!mountedRef.current) return;
|
||||||
containerRef.current.removeChild(containerRef.current.firstChild);
|
if (data.error || !data.id_token) {
|
||||||
|
setOidcError(data.error || t('auth.loginFailed'));
|
||||||
|
setOidcLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setOidcLoading(true);
|
||||||
|
setOidcError('');
|
||||||
|
await loginWithTelegramOIDC(data.id_token);
|
||||||
|
if (mountedRef.current) navigate('/');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
let message = t('common.error');
|
||||||
|
if (err && typeof err === 'object' && 'response' in err) {
|
||||||
|
const resp = (err as { response?: { data?: { detail?: string } } }).response;
|
||||||
|
if (resp?.data?.detail) message = resp.data.detail;
|
||||||
|
}
|
||||||
|
setOidcError(message);
|
||||||
|
} finally {
|
||||||
|
if (mountedRef.current) setOidcLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load OIDC script and init
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOIDC || !widgetConfig?.oidc_client_id) return;
|
||||||
|
|
||||||
|
const scriptId = 'telegram-login-oidc-script';
|
||||||
|
let script = document.getElementById(scriptId) as HTMLScriptElement | null;
|
||||||
|
|
||||||
|
const initTelegramLogin = () => {
|
||||||
|
if (window.Telegram?.Login) {
|
||||||
|
window.Telegram.Login.init(
|
||||||
|
{
|
||||||
|
client_id: Number(widgetConfig.oidc_client_id) || widgetConfig.oidc_client_id,
|
||||||
|
request_access: widgetConfig.request_access ? ['write'] : undefined,
|
||||||
|
lang: document.documentElement.lang || 'en',
|
||||||
|
},
|
||||||
|
(data) => handleOIDCCallbackRef.current?.(data),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!script) {
|
||||||
|
script = document.createElement('script');
|
||||||
|
script.id = scriptId;
|
||||||
|
script.src = 'https://oauth.telegram.org/js/telegram-login.js?3';
|
||||||
|
script.async = true;
|
||||||
|
script.onload = () => {
|
||||||
|
setScriptLoaded(true);
|
||||||
|
initTelegramLogin();
|
||||||
|
};
|
||||||
|
script.onerror = () => {
|
||||||
|
setOidcError(t('auth.loginFailed'));
|
||||||
|
};
|
||||||
|
document.head.appendChild(script);
|
||||||
|
} else {
|
||||||
|
// Script already loaded, just re-init
|
||||||
|
setScriptLoaded(true);
|
||||||
|
initTelegramLogin();
|
||||||
|
}
|
||||||
|
}, [isOIDC, widgetConfig?.oidc_client_id, widgetConfig?.request_access]);
|
||||||
|
|
||||||
|
// Legacy widget effect (only when NOT OIDC)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOIDC || !containerRef.current || !botUsername || !widgetConfig) return;
|
||||||
|
|
||||||
|
const container = containerRef.current;
|
||||||
|
while (container.firstChild) {
|
||||||
|
container.removeChild(container.firstChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current URL for redirect
|
|
||||||
const redirectUrl = `${window.location.origin}/auth/telegram/callback`;
|
const redirectUrl = `${window.location.origin}/auth/telegram/callback`;
|
||||||
|
|
||||||
// Create script element for Telegram Login Widget
|
|
||||||
const script = document.createElement('script');
|
const script = document.createElement('script');
|
||||||
script.src = 'https://telegram.org/js/telegram-widget.js?22';
|
script.src = 'https://telegram.org/js/telegram-widget.js?23';
|
||||||
script.setAttribute('data-telegram-login', botUsername);
|
script.setAttribute('data-telegram-login', botUsername);
|
||||||
script.setAttribute('data-size', 'large');
|
script.setAttribute('data-size', widgetConfig.size);
|
||||||
script.setAttribute('data-radius', '8');
|
script.setAttribute('data-radius', String(widgetConfig.radius));
|
||||||
|
script.setAttribute('data-userpic', String(widgetConfig.userpic));
|
||||||
script.setAttribute('data-auth-url', redirectUrl);
|
script.setAttribute('data-auth-url', redirectUrl);
|
||||||
script.setAttribute('data-request-access', 'write');
|
if (widgetConfig.request_access) {
|
||||||
|
script.setAttribute('data-request-access', 'write');
|
||||||
|
}
|
||||||
script.async = true;
|
script.async = true;
|
||||||
|
|
||||||
containerRef.current.appendChild(script);
|
container.appendChild(script);
|
||||||
}, [botUsername]);
|
|
||||||
|
return () => {
|
||||||
|
while (container.firstChild) {
|
||||||
|
container.removeChild(container.firstChild);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isOIDC, botUsername, widgetConfig]);
|
||||||
|
|
||||||
if (!botUsername || botUsername === 'your_bot') {
|
if (!botUsername || botUsername === 'your_bot') {
|
||||||
return (
|
return (
|
||||||
@@ -48,10 +147,35 @@ export default function TelegramLoginButton({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center space-y-4">
|
<div className="flex flex-col items-center space-y-4">
|
||||||
{/* Telegram Widget will be inserted here */}
|
{/* OIDC mode: custom button that opens popup */}
|
||||||
<div ref={containerRef} className="flex justify-center" />
|
{isOIDC ? (
|
||||||
|
<div className="flex flex-col items-center space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setOidcError('');
|
||||||
|
setOidcLoading(true);
|
||||||
|
if (window.Telegram?.Login) {
|
||||||
|
window.Telegram.Login.open();
|
||||||
|
} else {
|
||||||
|
setOidcLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={oidcLoading || !scriptLoaded}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-[#54a9eb] px-6 py-3 text-sm font-medium text-white shadow-sm transition-colors hover:bg-[#4a96d2] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
{oidcLoading ? t('common.loading') : t('auth.loginWithTelegram')}
|
||||||
|
</button>
|
||||||
|
{oidcError && <p className="text-xs text-red-500">{oidcError}</p>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Legacy widget mode: iframe-based widget */
|
||||||
|
<div ref={containerRef} className="flex justify-center" />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Fallback link for mobile */}
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="mb-2 text-xs text-gray-500">{t('auth.orOpenInApp')}</p>
|
<p className="mb-2 text-xs text-gray-500">{t('auth.orOpenInApp')}</p>
|
||||||
<a
|
<a
|
||||||
|
|||||||
74
src/components/admin/LocaleTabs.tsx
Normal file
74
src/components/admin/LocaleTabs.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
SUPPORTED_LOCALES,
|
||||||
|
LOCALE_META,
|
||||||
|
type SupportedLocale,
|
||||||
|
type LocaleDict,
|
||||||
|
} from '../../api/landings';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
interface LocaleTabsProps {
|
||||||
|
activeLocale: SupportedLocale;
|
||||||
|
onChange: (locale: SupportedLocale) => void;
|
||||||
|
/** Pass locale dicts to show a green dot indicator when content exists */
|
||||||
|
contentIndicators?: LocaleDict[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Horizontal locale tab bar for the admin landing editor.
|
||||||
|
* Shows a green dot on tabs that have content filled in.
|
||||||
|
*/
|
||||||
|
export function LocaleTabs({
|
||||||
|
activeLocale,
|
||||||
|
onChange,
|
||||||
|
contentIndicators,
|
||||||
|
className,
|
||||||
|
}: LocaleTabsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const hasContent = (locale: SupportedLocale): boolean => {
|
||||||
|
if (!contentIndicators || contentIndicators.length === 0) return false;
|
||||||
|
return contentIndicators.some((dict) => {
|
||||||
|
const value = dict[locale];
|
||||||
|
return typeof value === 'string' && value.trim().length > 0;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-1', className)}>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{SUPPORTED_LOCALES.map((locale) => {
|
||||||
|
const meta = LOCALE_META[locale];
|
||||||
|
const isActive = locale === activeLocale;
|
||||||
|
const filled = hasContent(locale);
|
||||||
|
const isRtl = meta.rtl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={locale}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(locale)}
|
||||||
|
dir={isRtl ? 'rtl' : 'ltr'}
|
||||||
|
className={cn(
|
||||||
|
'relative flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs 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 hover:bg-dark-700/50 hover:text-dark-300',
|
||||||
|
)}
|
||||||
|
aria-label={`${t('admin.landings.localeTab')}: ${meta.name}`}
|
||||||
|
aria-pressed={isActive}
|
||||||
|
>
|
||||||
|
<span>{meta.flag}</span>
|
||||||
|
<span>{meta.name}</span>
|
||||||
|
{filled && !isActive && (
|
||||||
|
<span className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-success-500" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-dark-500">{t('admin.landings.localeHint')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/components/admin/LocalizedInput.tsx
Normal file
70
src/components/admin/LocalizedInput.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { LOCALE_META, type LocaleDict, type SupportedLocale } from '../../api/landings';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
interface LocalizedInputProps {
|
||||||
|
value: LocaleDict;
|
||||||
|
onChange: (value: LocaleDict) => void;
|
||||||
|
locale: SupportedLocale;
|
||||||
|
placeholder?: string;
|
||||||
|
multiline?: boolean;
|
||||||
|
rows?: number;
|
||||||
|
maxLength?: number;
|
||||||
|
id?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An input (or textarea) that edits a single locale key within a LocaleDict.
|
||||||
|
* Shows `value[locale]` and updates the dict immutably on change.
|
||||||
|
*/
|
||||||
|
export function LocalizedInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
locale,
|
||||||
|
placeholder,
|
||||||
|
multiline = false,
|
||||||
|
rows = 2,
|
||||||
|
maxLength,
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
}: LocalizedInputProps) {
|
||||||
|
const currentValue = value[locale] ?? '';
|
||||||
|
const isRtl = LOCALE_META[locale].rtl;
|
||||||
|
|
||||||
|
const handleChange = (newText: string) => {
|
||||||
|
onChange({ ...value, [locale]: newText });
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputClasses = cn(
|
||||||
|
'w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-100 outline-none focus:border-accent-500',
|
||||||
|
className,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (multiline) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
id={id}
|
||||||
|
dir={isRtl ? 'rtl' : 'ltr'}
|
||||||
|
value={currentValue}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={rows}
|
||||||
|
maxLength={maxLength}
|
||||||
|
className={inputClasses}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
dir={isRtl ? 'rtl' : 'ltr'}
|
||||||
|
value={currentValue}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
maxLength={maxLength}
|
||||||
|
className={inputClasses}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
src/components/admin/SortableFeatureItem.tsx
Normal file
89
src/components/admin/SortableFeatureItem.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import { GripIcon, TrashIcon } from '../icons/LandingIcons';
|
||||||
|
import { LocalizedInput } from './LocalizedInput';
|
||||||
|
import type { AdminLandingFeature, LocaleDict, SupportedLocale } from '../../api/landings';
|
||||||
|
|
||||||
|
export type FeatureWithId = AdminLandingFeature & { _id: string };
|
||||||
|
|
||||||
|
interface SortableFeatureProps {
|
||||||
|
feature: FeatureWithId;
|
||||||
|
index: number;
|
||||||
|
locale: SupportedLocale;
|
||||||
|
onUpdateIcon: (index: number, value: string) => void;
|
||||||
|
onUpdateLocalized: (index: number, field: 'title' | 'description', value: LocaleDict) => void;
|
||||||
|
onRemove: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SortableFeatureItem({
|
||||||
|
feature,
|
||||||
|
index,
|
||||||
|
locale,
|
||||||
|
onUpdateIcon,
|
||||||
|
onUpdateLocalized,
|
||||||
|
onRemove,
|
||||||
|
}: SortableFeatureProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: feature._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
zIndex: isDragging ? 50 : undefined,
|
||||||
|
position: isDragging ? 'relative' : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-2 rounded-lg border p-3',
|
||||||
|
isDragging ? 'border-accent-500/50 bg-dark-700' : 'border-dark-700 bg-dark-800/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="mt-2 flex-shrink-0 cursor-grab touch-none text-dark-500 hover:text-dark-300 active:cursor-grabbing"
|
||||||
|
>
|
||||||
|
<GripIcon />
|
||||||
|
</button>
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={feature.icon}
|
||||||
|
onChange={(e) => onUpdateIcon(index, e.target.value)}
|
||||||
|
placeholder={t('admin.landings.featureIcon')}
|
||||||
|
className="w-16 rounded-lg border border-dark-700 bg-dark-800 px-2 py-1.5 text-center text-sm text-dark-100 outline-none focus:border-accent-500"
|
||||||
|
/>
|
||||||
|
<LocalizedInput
|
||||||
|
value={feature.title}
|
||||||
|
onChange={(v) => onUpdateLocalized(index, 'title', v)}
|
||||||
|
locale={locale}
|
||||||
|
placeholder={t('admin.landings.featureTitle')}
|
||||||
|
className="min-w-0 flex-1 rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<LocalizedInput
|
||||||
|
value={feature.description}
|
||||||
|
onChange={(v) => onUpdateLocalized(index, 'description', v)}
|
||||||
|
locale={locale}
|
||||||
|
placeholder={t('admin.landings.featureDesc')}
|
||||||
|
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onRemove(index)}
|
||||||
|
className="mt-2 flex-shrink-0 text-dark-500 hover:text-error-400"
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
251
src/components/admin/SortableSelectedMethodCard.tsx
Normal file
251
src/components/admin/SortableSelectedMethodCard.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import { GripIcon, TrashIcon } from '../icons/LandingIcons';
|
||||||
|
import type { AdminLandingPaymentMethod, EditableMethodField } from '../../api/landings';
|
||||||
|
import type { PaymentMethodSubOptionInfo } from '../../types';
|
||||||
|
|
||||||
|
export type MethodWithId = AdminLandingPaymentMethod & { _id: string };
|
||||||
|
|
||||||
|
const ChevronDownIcon = ({ open }: { open: boolean }) => (
|
||||||
|
<svg
|
||||||
|
className={cn('h-5 w-5 transition-transform', open && 'rotate-180')}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface SortableSelectedMethodProps {
|
||||||
|
method: MethodWithId;
|
||||||
|
availableSubOptions: PaymentMethodSubOptionInfo[] | null;
|
||||||
|
onUpdate: (methodId: string, field: EditableMethodField, value: string | number | null) => void;
|
||||||
|
onSubOptionsChange: (methodId: string, subOptions: Record<string, boolean>) => void;
|
||||||
|
onRemove: (methodId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SortableSelectedMethodCard({
|
||||||
|
method,
|
||||||
|
availableSubOptions,
|
||||||
|
onUpdate,
|
||||||
|
onSubOptionsChange,
|
||||||
|
onRemove,
|
||||||
|
}: SortableSelectedMethodProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: method._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
zIndex: isDragging ? 50 : undefined,
|
||||||
|
position: isDragging ? 'relative' : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border',
|
||||||
|
isDragging ? 'border-accent-500/50 bg-dark-700' : 'border-dark-700 bg-dark-800/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<button
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="flex-shrink-0 cursor-grab touch-none text-dark-500 hover:text-dark-300 active:cursor-grabbing"
|
||||||
|
>
|
||||||
|
<GripIcon />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setExpanded((v) => !v)} className="min-w-0 flex-1 text-start">
|
||||||
|
<span className="truncate text-sm text-dark-100">{method.display_name}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
className="flex-shrink-0 text-dark-500 hover:text-dark-300"
|
||||||
|
>
|
||||||
|
<ChevronDownIcon open={expanded} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onRemove(method.method_id)}
|
||||||
|
className="flex-shrink-0 text-dark-500 hover:text-error-400"
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{expanded && (
|
||||||
|
<div className="space-y-3 border-t border-dark-700 px-3 py-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-dark-500">
|
||||||
|
{t('admin.landings.methodDisplayName', 'Display name')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={method.display_name}
|
||||||
|
onChange={(e) => onUpdate(method.method_id, 'display_name', e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-dark-500">
|
||||||
|
{t('admin.landings.methodDescription', 'Description')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={method.description ?? ''}
|
||||||
|
onChange={(e) => onUpdate(method.method_id, 'description', e.target.value || null)}
|
||||||
|
placeholder={t('admin.landings.methodDescPlaceholder', 'Optional description')}
|
||||||
|
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-dark-500">
|
||||||
|
{t('admin.landings.methodIconUrl', 'Icon URL')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={method.icon_url ?? ''}
|
||||||
|
onChange={(e) => onUpdate(method.method_id, 'icon_url', e.target.value || null)}
|
||||||
|
placeholder="https://..."
|
||||||
|
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-dark-500">
|
||||||
|
{t('admin.landings.methodMinAmount', 'Min amount (kopeks)')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={method.min_amount_kopeks ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdate(
|
||||||
|
method.method_id,
|
||||||
|
'min_amount_kopeks',
|
||||||
|
e.target.value ? Math.max(0, Math.floor(Number(e.target.value))) : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="—"
|
||||||
|
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-dark-500">
|
||||||
|
{t('admin.landings.methodMaxAmount', 'Max amount (kopeks)')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={method.max_amount_kopeks ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdate(
|
||||||
|
method.method_id,
|
||||||
|
'max_amount_kopeks',
|
||||||
|
e.target.value ? Math.max(0, Math.floor(Number(e.target.value))) : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="—"
|
||||||
|
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-dark-500">
|
||||||
|
{t('admin.landings.methodCurrency', 'Currency')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={method.currency ?? ''}
|
||||||
|
onChange={(e) => onUpdate(method.method_id, 'currency', e.target.value || null)}
|
||||||
|
placeholder="RUB"
|
||||||
|
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-dark-500">
|
||||||
|
{t('admin.landings.methodReturnUrl', 'Return URL after payment')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={method.return_url ?? ''}
|
||||||
|
onChange={(e) => onUpdate(method.method_id, 'return_url', e.target.value || null)}
|
||||||
|
placeholder={t(
|
||||||
|
'admin.landings.methodReturnUrlPlaceholder',
|
||||||
|
'Default: cabinet success page. Use {token} for purchase token',
|
||||||
|
)}
|
||||||
|
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{availableSubOptions && availableSubOptions.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs text-dark-500">
|
||||||
|
{t('admin.landings.methodSubOptions', 'Payment sub-options')}
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{availableSubOptions.map((opt) => {
|
||||||
|
// Missing keys treated as enabled (opt-out model)
|
||||||
|
const enabled = method.sub_options?.[opt.id] !== false;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.id}
|
||||||
|
type="button"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={enabled}
|
||||||
|
onClick={() => {
|
||||||
|
const current =
|
||||||
|
method.sub_options ??
|
||||||
|
Object.fromEntries(availableSubOptions.map((o) => [o.id, true]));
|
||||||
|
onSubOptionsChange(method.method_id, { ...current, [opt.id]: !enabled });
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors',
|
||||||
|
enabled
|
||||||
|
? 'border-accent-500/30 bg-accent-500/10 text-accent-300'
|
||||||
|
: 'border-dark-700 bg-dark-800 text-dark-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
'flex h-3.5 w-3.5 items-center justify-center rounded',
|
||||||
|
enabled
|
||||||
|
? 'bg-accent-500 text-white'
|
||||||
|
: 'border border-dark-600 bg-dark-700',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{enabled && (
|
||||||
|
<svg
|
||||||
|
className="h-2.5 w-2.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={3}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{opt.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -67,6 +67,8 @@ export const MENU_SECTIONS: MenuSection[] = [
|
|||||||
'INTERFACE_SUBSCRIPTION',
|
'INTERFACE_SUBSCRIPTION',
|
||||||
'CONNECT_BUTTON',
|
'CONNECT_BUTTON',
|
||||||
'MINIAPP',
|
'MINIAPP',
|
||||||
|
'TELEGRAM_WIDGET',
|
||||||
|
'TELEGRAM_OIDC',
|
||||||
'HAPP',
|
'HAPP',
|
||||||
'SKIP',
|
'SKIP',
|
||||||
'ADDITIONAL',
|
'ADDITIONAL',
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export * from './FavoritesTab';
|
|||||||
export * from './SettingsTab';
|
export * from './SettingsTab';
|
||||||
export * from './SettingsMobileTabs';
|
export * from './SettingsMobileTabs';
|
||||||
export * from './SettingsSearch';
|
export * from './SettingsSearch';
|
||||||
|
export * from './LocaleTabs';
|
||||||
|
export * from './LocalizedInput';
|
||||||
|
|
||||||
// Constants and utils
|
// Constants and utils
|
||||||
export * from './constants';
|
export * from './constants';
|
||||||
|
|||||||
@@ -236,15 +236,24 @@ export default function SubscriptionCardActive({
|
|||||||
{t('dashboard.connectDevice')}
|
{t('dashboard.connectDevice')}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 text-[11px] text-dark-50/30">
|
<div className="mt-0.5 text-[11px] text-dark-50/30">
|
||||||
{t('dashboard.devicesOfMax', {
|
{subscription.device_limit === 0
|
||||||
used: connectedDevices,
|
? t('dashboard.devicesConnectedUnlimited', { used: connectedDevices })
|
||||||
max: subscription.device_limit,
|
: t('dashboard.devicesOfMax', {
|
||||||
})}
|
used: connectedDevices,
|
||||||
|
max: subscription.device_limit,
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Device indicator */}
|
{/* Device indicator */}
|
||||||
{subscription.device_limit <= 10 ? (
|
{subscription.device_limit === 0 ? (
|
||||||
|
<div
|
||||||
|
className="flex flex-shrink-0 items-center text-lg text-dark-50/40"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
∞
|
||||||
|
</div>
|
||||||
|
) : subscription.device_limit <= 10 ? (
|
||||||
<div className="flex flex-shrink-0 gap-1.5" aria-hidden="true">
|
<div className="flex flex-shrink-0 gap-1.5" aria-hidden="true">
|
||||||
{Array.from({ length: subscription.device_limit }, (_, i) => (
|
{Array.from({ length: subscription.device_limit }, (_, i) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -175,7 +175,10 @@ export default function TrialOfferCard({
|
|||||||
value: trialInfo.traffic_limit_gb === 0 ? '∞' : String(trialInfo.traffic_limit_gb),
|
value: trialInfo.traffic_limit_gb === 0 ? '∞' : String(trialInfo.traffic_limit_gb),
|
||||||
label: t('common.units.gb'),
|
label: t('common.units.gb'),
|
||||||
},
|
},
|
||||||
{ value: String(trialInfo.device_limit), label: t('subscription.trial.devices') },
|
{
|
||||||
|
value: trialInfo.device_limit === 0 ? '∞' : String(trialInfo.device_limit),
|
||||||
|
label: t('subscription.trial.devices'),
|
||||||
|
},
|
||||||
].map((stat, i) => (
|
].map((stat, i) => (
|
||||||
<div key={i} className="text-center">
|
<div key={i} className="text-center">
|
||||||
<div className="text-4xl font-extrabold leading-none tracking-tight text-dark-50">
|
<div className="text-4xl font-extrabold leading-none tracking-tight text-dark-50">
|
||||||
|
|||||||
69
src/components/icons/LandingIcons.tsx
Normal file
69
src/components/icons/LandingIcons.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
interface IconProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackIcon({ className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={cn('h-5 w-5 text-dark-400', 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 function PlusIcon({ className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<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="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrashIcon({ className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<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="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 function GripIcon({ className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<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.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -235,13 +235,13 @@
|
|||||||
"connectDevice": "Connect Device",
|
"connectDevice": "Connect Device",
|
||||||
"devicesConnected": "{{count}} connected",
|
"devicesConnected": "{{count}} connected",
|
||||||
"devicesOfMax": "{{used}} of {{max}} connected",
|
"devicesOfMax": "{{used}} of {{max}} connected",
|
||||||
|
"devicesConnectedUnlimited": "{{used}} connected · unlimited",
|
||||||
"devicesShort": "dev.",
|
"devicesShort": "dev.",
|
||||||
"trafficUsage": "{{used}} / {{limit}} GB",
|
"trafficUsage": "{{used}} / {{limit}} GB",
|
||||||
"trafficUsageTitle": "Traffic Usage",
|
"trafficUsageTitle": "Traffic Usage",
|
||||||
"usedTraffic": "used {{amount}}",
|
"usedTraffic": "used {{amount}}",
|
||||||
"usedSuffix": "used",
|
"usedSuffix": "used",
|
||||||
"unlimited": "Unlimited",
|
"unlimited": "Unlimited",
|
||||||
"unlimitedTraffic": "Unlimited traffic",
|
|
||||||
"tariff": "Tariff",
|
"tariff": "Tariff",
|
||||||
"validUntil": "until {{date}}",
|
"validUntil": "until {{date}}",
|
||||||
"remaining": "Remaining",
|
"remaining": "Remaining",
|
||||||
@@ -1043,7 +1043,8 @@
|
|||||||
"roleAssign": "Role Assignment",
|
"roleAssign": "Role Assignment",
|
||||||
"policies": "Access Policies",
|
"policies": "Access Policies",
|
||||||
"auditLog": "Audit Log",
|
"auditLog": "Audit Log",
|
||||||
"salesStats": "Sales Statistics"
|
"salesStats": "Sales Statistics",
|
||||||
|
"landings": "Landings"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "Admin Panel",
|
"title": "Admin Panel",
|
||||||
@@ -1076,7 +1077,8 @@
|
|||||||
"roleAssignDesc": "Assign and revoke user roles",
|
"roleAssignDesc": "Assign and revoke user roles",
|
||||||
"policiesDesc": "Configure ABAC access policies",
|
"policiesDesc": "Configure ABAC access policies",
|
||||||
"auditLogDesc": "Review system activity and access history",
|
"auditLogDesc": "Review system activity and access history",
|
||||||
"salesStatsDesc": "Sales analytics and trends"
|
"salesStatsDesc": "Sales analytics and trends",
|
||||||
|
"landingsDesc": "Quick purchase landing pages"
|
||||||
},
|
},
|
||||||
"salesStats": {
|
"salesStats": {
|
||||||
"title": "Sales Statistics",
|
"title": "Sales Statistics",
|
||||||
@@ -1679,7 +1681,20 @@
|
|||||||
"projectName": "Project name",
|
"projectName": "Project name",
|
||||||
"quickPresets": "Quick presets",
|
"quickPresets": "Quick presets",
|
||||||
"resetAllColors": "Reset all colors",
|
"resetAllColors": "Reset all colors",
|
||||||
"statusColors": "Status colors"
|
"statusColors": "Status colors",
|
||||||
|
"categories": {
|
||||||
|
"TELEGRAM_WIDGET": "Telegram Login Widget",
|
||||||
|
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
|
||||||
|
},
|
||||||
|
"settingNames": {
|
||||||
|
"Telegram Widget Size": "Widget Size",
|
||||||
|
"Telegram Widget Radius": "Corner Radius",
|
||||||
|
"Telegram Widget Userpic": "Show User Photo",
|
||||||
|
"Telegram Widget Request Access": "Request Access",
|
||||||
|
"telegramOidcEnabled": "OIDC Enabled",
|
||||||
|
"telegramOidcClientId": "Client ID (Bot ID)",
|
||||||
|
"telegramOidcClientSecret": "Client Secret"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"accentColor": "Accent color",
|
"accentColor": "Accent color",
|
||||||
@@ -1908,6 +1923,10 @@
|
|||||||
"noPeriodsHint": "No added periods. Add at least one period.",
|
"noPeriodsHint": "No added periods. Add at least one period.",
|
||||||
"daysShort": "d.",
|
"daysShort": "d.",
|
||||||
"serversTitle": "Servers",
|
"serversTitle": "Servers",
|
||||||
|
"externalSquadTitle": "External Squad",
|
||||||
|
"externalSquadHint": "Assign a RemnaWave external squad for users on this tariff. When a subscription is created, the user will be automatically added to the selected squad.",
|
||||||
|
"noExternalSquad": "No external squad",
|
||||||
|
"externalSquadUsers": "users",
|
||||||
"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",
|
"promoGroupsTitle": "Promo Groups",
|
||||||
@@ -2012,6 +2031,7 @@
|
|||||||
"subtitle": "Manage advertising links",
|
"subtitle": "Manage advertising links",
|
||||||
"createButton": "Create",
|
"createButton": "Create",
|
||||||
"noData": "No ad campaigns",
|
"noData": "No ad campaigns",
|
||||||
|
"loadMore": "Load more",
|
||||||
"bonusType": {
|
"bonusType": {
|
||||||
"balance": "Balance",
|
"balance": "Balance",
|
||||||
"subscription": "Subscription",
|
"subscription": "Subscription",
|
||||||
@@ -2866,6 +2886,7 @@
|
|||||||
"apps": "Apps",
|
"apps": "Apps",
|
||||||
"email_templates": "Email templates",
|
"email_templates": "Email templates",
|
||||||
"pinned_messages": "Pinned messages",
|
"pinned_messages": "Pinned messages",
|
||||||
|
"landings": "Landing pages",
|
||||||
"updates": "Updates"
|
"updates": "Updates"
|
||||||
},
|
},
|
||||||
"permissionActions": {
|
"permissionActions": {
|
||||||
@@ -3077,6 +3098,7 @@
|
|||||||
"filters": {
|
"filters": {
|
||||||
"title": "Filters",
|
"title": "Filters",
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
|
"user": "User",
|
||||||
"action": "Action",
|
"action": "Action",
|
||||||
"actionPlaceholder": "Search by action...",
|
"actionPlaceholder": "Search by action...",
|
||||||
"resource": "Resource type",
|
"resource": "Resource type",
|
||||||
@@ -3138,6 +3160,74 @@
|
|||||||
"loadFailed": "Failed to load audit log",
|
"loadFailed": "Failed to load audit log",
|
||||||
"retry": "Try again"
|
"retry": "Try again"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"landings": {
|
||||||
|
"title": "Landing Pages",
|
||||||
|
"create": "Create Landing",
|
||||||
|
"edit": "Edit",
|
||||||
|
"slug": "URL Identifier",
|
||||||
|
"slugHint": "Lowercase, numbers and hyphens",
|
||||||
|
"pageTitle": "Title",
|
||||||
|
"subtitle": "Subtitle",
|
||||||
|
"footerText": "Footer (HTML)",
|
||||||
|
"features": "Features",
|
||||||
|
"addFeature": "Add",
|
||||||
|
"featureIcon": "Icon",
|
||||||
|
"featureTitle": "Title",
|
||||||
|
"featureDesc": "Description",
|
||||||
|
"tariffs": "Plans",
|
||||||
|
"selectTariffs": "Select plans",
|
||||||
|
"periods": "Periods",
|
||||||
|
"paymentMethods": "Payment Methods",
|
||||||
|
"addMethod": "Add method",
|
||||||
|
"methodId": "Method ID",
|
||||||
|
"methodName": "Name",
|
||||||
|
"methodDesc": "Description",
|
||||||
|
"methodIcon": "Icon URL",
|
||||||
|
"gifts": "Gifts",
|
||||||
|
"giftEnabled": "Gift purchases",
|
||||||
|
"customCss": "CSS",
|
||||||
|
"seo": "SEO",
|
||||||
|
"metaTitle": "Meta Title",
|
||||||
|
"metaDesc": "Meta Description",
|
||||||
|
"active": "Active",
|
||||||
|
"inactive": "Inactive",
|
||||||
|
"purchases": "purchases",
|
||||||
|
"copyUrl": "Copy URL",
|
||||||
|
"urlCopied": "URL copied",
|
||||||
|
"deleteConfirm": "Delete landing \"{{title}}\"?",
|
||||||
|
"created": "Landing created",
|
||||||
|
"updated": "Landing updated",
|
||||||
|
"deleted": "Landing deleted",
|
||||||
|
"saveOrder": "Save order",
|
||||||
|
"orderSaved": "Order saved",
|
||||||
|
"general": "General",
|
||||||
|
"content": "Content",
|
||||||
|
"save": "Save",
|
||||||
|
"back": "Back",
|
||||||
|
"invalidSlug": "Slug can only contain lowercase letters, numbers, and hyphens",
|
||||||
|
"titleRequired": "Title is required",
|
||||||
|
"noTariffs": "Select at least one tariff",
|
||||||
|
"noPaymentMethods": "Add at least one payment method",
|
||||||
|
"selectMethods": "Select available methods",
|
||||||
|
"noSystemMethods": "No payment methods configured in the system",
|
||||||
|
"methodOrder": "Drag to reorder",
|
||||||
|
"methodSubOptions": "Payment sub-options",
|
||||||
|
"loadingPeriods": "Loading...",
|
||||||
|
"periodDaySuffix": "d",
|
||||||
|
"localeTab": "Language",
|
||||||
|
"localeHint": "Switch language to edit text in that language",
|
||||||
|
"discount": "Discount",
|
||||||
|
"discountEnabled": "Enable discount",
|
||||||
|
"discountPercent": "Discount %",
|
||||||
|
"discountStartsAt": "Start date",
|
||||||
|
"discountEndsAt": "End date",
|
||||||
|
"discountBadge": "Banner text (optional)",
|
||||||
|
"discountBadgePlaceholder": "e.g. Spring sale!",
|
||||||
|
"discountOverrides": "Per-tariff overrides",
|
||||||
|
"discountOverridesHint": "Leave empty to use global discount",
|
||||||
|
"discountPreview": "Preview",
|
||||||
|
"discountActive": "Discount"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"adminUpdates": {
|
"adminUpdates": {
|
||||||
@@ -3865,5 +3955,89 @@
|
|||||||
"error": "Failed to merge accounts. Please try again later.",
|
"error": "Failed to merge accounts. Please try again later.",
|
||||||
"expiresIn": "Expires in {{minutes}}",
|
"expiresIn": "Expires in {{minutes}}",
|
||||||
"merging": "Merging..."
|
"merging": "Merging..."
|
||||||
|
},
|
||||||
|
"landing": {
|
||||||
|
"notFound": "Page not found",
|
||||||
|
"forMe": "For me",
|
||||||
|
"asGift": "As a gift",
|
||||||
|
"contactLabel": "Your email or @username",
|
||||||
|
"yourContact": "Your email or @username",
|
||||||
|
"contactPlaceholder": "email@example.com or @username",
|
||||||
|
"contactHint": "Enter email or Telegram @username",
|
||||||
|
"recipientLabel": "Gift recipient",
|
||||||
|
"recipientPlaceholder": "Recipient email or @username",
|
||||||
|
"giftMessageLabel": "Greeting (optional)",
|
||||||
|
"giftMessagePlaceholder": "Write a greeting...",
|
||||||
|
"chooseTariff": "Choose a plan",
|
||||||
|
"devices": "devices",
|
||||||
|
"paymentMethod": "Payment method",
|
||||||
|
"processing": "Processing...",
|
||||||
|
"payButton": "Pay {{price}}",
|
||||||
|
"accessFor": "Access for {{period}}",
|
||||||
|
"purchaseError": "Failed to create payment",
|
||||||
|
"purchaseNotFound": "Purchase not found",
|
||||||
|
"awaitingPayment": "Awaiting payment",
|
||||||
|
"awaitingPaymentDesc": "Complete the payment in the opened window",
|
||||||
|
"purchaseSuccess": "Payment successful!",
|
||||||
|
"keySentTo": "Subscription key sent to {{contact}}",
|
||||||
|
"giftSentTo": "Gift sent to {{contact}}",
|
||||||
|
"purchaseFailed": "Payment failed",
|
||||||
|
"purchaseFailedDesc": "Payment was unsuccessful. Try again.",
|
||||||
|
"subscriptionLink": "Subscription link",
|
||||||
|
"daysAccess": "days of access",
|
||||||
|
"error": "Error",
|
||||||
|
"gb": "GB",
|
||||||
|
"selectedTariff": "Tariff",
|
||||||
|
"period": "Period",
|
||||||
|
"total": "Total",
|
||||||
|
"pay": "Pay",
|
||||||
|
"choosePeriod": "Choose period",
|
||||||
|
"copy": "Copy",
|
||||||
|
"copied": "Copied!",
|
||||||
|
"copyLink": "Copy link",
|
||||||
|
"giftToggleLabel": "Purchase type",
|
||||||
|
"pollTimedOut": "Taking longer than expected",
|
||||||
|
"pollTimedOutDesc": "Payment processing is taking longer than usual. You can try checking again.",
|
||||||
|
"pendingActivation": "Subscription ready to activate",
|
||||||
|
"pendingActivationDesc": "You already have an active subscription. Activating will replace it.",
|
||||||
|
"activateNow": "Activate now",
|
||||||
|
"activating": "Activating...",
|
||||||
|
"activationFailed": "Activation failed",
|
||||||
|
"giftMessage": "Message",
|
||||||
|
"cabinetReady": "Your account is ready",
|
||||||
|
"cabinetEmail": "Email",
|
||||||
|
"cabinetPassword": "Password",
|
||||||
|
"goToCabinet": "Go to Cabinet",
|
||||||
|
"saveCredentials": "Save these login credentials",
|
||||||
|
"credentialsSentToEmail": "Login credentials sent to your email",
|
||||||
|
"giftSentSuccess": "Gift sent!",
|
||||||
|
"giftSentDesc": "Recipient will be notified by email",
|
||||||
|
"giftPendingActivationDesc": "The recipient already has an active subscription. They will receive a link to activate the gift.",
|
||||||
|
"autoLoginFailed": "Auto-login failed",
|
||||||
|
"autoLoginProcessing": "Signing in...",
|
||||||
|
"discount": {
|
||||||
|
"days": "d",
|
||||||
|
"hours": "h",
|
||||||
|
"minutes": "m",
|
||||||
|
"seconds": "s"
|
||||||
|
},
|
||||||
|
"periodLabels": {
|
||||||
|
"d1": "1 day",
|
||||||
|
"d2": "2 days",
|
||||||
|
"d3": "3 days",
|
||||||
|
"d5": "5 days",
|
||||||
|
"d7": "1 week",
|
||||||
|
"d14": "2 weeks",
|
||||||
|
"d30": "1 month",
|
||||||
|
"d60": "2 months",
|
||||||
|
"d90": "3 months",
|
||||||
|
"d180": "6 months",
|
||||||
|
"d365": "1 year",
|
||||||
|
"d456": "1 year + 3 mo.",
|
||||||
|
"nDays_one": "{{count}} day",
|
||||||
|
"nDays_other": "{{count}} days",
|
||||||
|
"nMonths_one": "{{count}} month",
|
||||||
|
"nMonths_other": "{{count}} months"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -866,7 +866,8 @@
|
|||||||
"roleAssign": "تخصیص نقش",
|
"roleAssign": "تخصیص نقش",
|
||||||
"policies": "سیاستهای دسترسی",
|
"policies": "سیاستهای دسترسی",
|
||||||
"auditLog": "گزارش بازرسی",
|
"auditLog": "گزارش بازرسی",
|
||||||
"salesStats": "آمار فروش"
|
"salesStats": "آمار فروش",
|
||||||
|
"landings": "صفحات فرود"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "پنل مدیریت",
|
"title": "پنل مدیریت",
|
||||||
@@ -899,7 +900,8 @@
|
|||||||
"roleAssignDesc": "تخصیص و لغو نقشهای کاربران",
|
"roleAssignDesc": "تخصیص و لغو نقشهای کاربران",
|
||||||
"policiesDesc": "تنظیم سیاستهای کنترل دسترسی ABAC",
|
"policiesDesc": "تنظیم سیاستهای کنترل دسترسی ABAC",
|
||||||
"auditLogDesc": "بررسی فعالیت سیستم و تاریخچه دسترسی",
|
"auditLogDesc": "بررسی فعالیت سیستم و تاریخچه دسترسی",
|
||||||
"salesStatsDesc": "تحلیل فروش و روندها"
|
"salesStatsDesc": "تحلیل فروش و روندها",
|
||||||
|
"landingsDesc": "صفحات خرید سریع"
|
||||||
},
|
},
|
||||||
"salesStats": {
|
"salesStats": {
|
||||||
"title": "آمار فروش",
|
"title": "آمار فروش",
|
||||||
@@ -1339,7 +1341,20 @@
|
|||||||
"projectName": "نام پروژه",
|
"projectName": "نام پروژه",
|
||||||
"quickPresets": "پیشتنظیمهای سریع",
|
"quickPresets": "پیشتنظیمهای سریع",
|
||||||
"resetAllColors": "بازنشانی همه رنگها",
|
"resetAllColors": "بازنشانی همه رنگها",
|
||||||
"statusColors": "رنگهای وضعیت"
|
"statusColors": "رنگهای وضعیت",
|
||||||
|
"categories": {
|
||||||
|
"TELEGRAM_WIDGET": "Telegram Login Widget",
|
||||||
|
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
|
||||||
|
},
|
||||||
|
"settingNames": {
|
||||||
|
"Telegram Widget Size": "اندازه ویجت",
|
||||||
|
"Telegram Widget Radius": "شعاع گوشه",
|
||||||
|
"Telegram Widget Userpic": "نمایش تصویر کاربر",
|
||||||
|
"Telegram Widget Request Access": "درخواست دسترسی",
|
||||||
|
"telegramOidcEnabled": "فعالسازی OIDC",
|
||||||
|
"telegramOidcClientId": "Client ID (شناسه ربات)",
|
||||||
|
"telegramOidcClientSecret": "Client Secret"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"color": "رنگ دکمه",
|
"color": "رنگ دکمه",
|
||||||
@@ -1552,6 +1567,10 @@
|
|||||||
"noPeriodsHint": "هیچ دورهای اضافه نشده. حداقل یک دوره اضافه کنید.",
|
"noPeriodsHint": "هیچ دورهای اضافه نشده. حداقل یک دوره اضافه کنید.",
|
||||||
"daysShort": "روز",
|
"daysShort": "روز",
|
||||||
"serversTitle": "سرورها",
|
"serversTitle": "سرورها",
|
||||||
|
"externalSquadTitle": "گروه خارجی",
|
||||||
|
"externalSquadHint": "یک گروه خارجی RemnaWave برای کاربران این تعرفه تعیین کنید. هنگام ایجاد اشتراک، کاربر به طور خودکار به گروه انتخاب شده اضافه میشود.",
|
||||||
|
"noExternalSquad": "بدون گروه خارجی",
|
||||||
|
"externalSquadUsers": "کاربران",
|
||||||
"serversTabHint": "سرورهای موجود در این تعرفه را انتخاب کنید.",
|
"serversTabHint": "سرورهای موجود در این تعرفه را انتخاب کنید.",
|
||||||
"noServersAvailable": "هیچ سروری در دسترس نیست",
|
"noServersAvailable": "هیچ سروری در دسترس نیست",
|
||||||
"promoGroupsTitle": "گروههای تبلیغاتی",
|
"promoGroupsTitle": "گروههای تبلیغاتی",
|
||||||
@@ -2599,6 +2618,7 @@
|
|||||||
"apps": "برنامهها",
|
"apps": "برنامهها",
|
||||||
"email_templates": "قالبهای ایمیل",
|
"email_templates": "قالبهای ایمیل",
|
||||||
"pinned_messages": "پیامهای سنجاقشده",
|
"pinned_messages": "پیامهای سنجاقشده",
|
||||||
|
"landings": "صفحات فرود",
|
||||||
"updates": "بهروزرسانیها"
|
"updates": "بهروزرسانیها"
|
||||||
},
|
},
|
||||||
"permissionActions": {
|
"permissionActions": {
|
||||||
@@ -2806,6 +2826,7 @@
|
|||||||
"filters": {
|
"filters": {
|
||||||
"title": "فیلترها",
|
"title": "فیلترها",
|
||||||
"active": "فعال",
|
"active": "فعال",
|
||||||
|
"user": "کاربر",
|
||||||
"action": "عملیات",
|
"action": "عملیات",
|
||||||
"actionPlaceholder": "جستجو بر اساس عملیات...",
|
"actionPlaceholder": "جستجو بر اساس عملیات...",
|
||||||
"resource": "نوع منبع",
|
"resource": "نوع منبع",
|
||||||
@@ -2864,6 +2885,63 @@
|
|||||||
"loadFailed": "بارگذاری گزارش بازرسی ناموفق بود",
|
"loadFailed": "بارگذاری گزارش بازرسی ناموفق بود",
|
||||||
"retry": "تلاش مجدد"
|
"retry": "تلاش مجدد"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"landings": {
|
||||||
|
"title": "صفحات فرود",
|
||||||
|
"create": "ایجاد صفحه فرود",
|
||||||
|
"edit": "ویرایش",
|
||||||
|
"slug": "شناسه URL",
|
||||||
|
"slugHint": "حروف کوچک، اعداد و خط تیره",
|
||||||
|
"pageTitle": "عنوان",
|
||||||
|
"subtitle": "زیرعنوان",
|
||||||
|
"footerText": "متن پاورقی (HTML)",
|
||||||
|
"features": "ویژگیها",
|
||||||
|
"addFeature": "افزودن",
|
||||||
|
"featureIcon": "آیکون",
|
||||||
|
"featureTitle": "عنوان",
|
||||||
|
"featureDesc": "توضیحات",
|
||||||
|
"tariffs": "طرحها",
|
||||||
|
"selectTariffs": "انتخاب طرحها",
|
||||||
|
"periods": "دورهها",
|
||||||
|
"paymentMethods": "روشهای پرداخت",
|
||||||
|
"addMethod": "افزودن روش",
|
||||||
|
"methodId": "شناسه روش",
|
||||||
|
"methodName": "نام",
|
||||||
|
"methodDesc": "توضیحات",
|
||||||
|
"methodIcon": "URL آیکون",
|
||||||
|
"gifts": "هدایا",
|
||||||
|
"giftEnabled": "خرید هدیه",
|
||||||
|
"customCss": "CSS",
|
||||||
|
"seo": "SEO",
|
||||||
|
"metaTitle": "عنوان متا",
|
||||||
|
"metaDesc": "توضیحات متا",
|
||||||
|
"active": "فعال",
|
||||||
|
"inactive": "غیرفعال",
|
||||||
|
"purchases": "خرید",
|
||||||
|
"copyUrl": "کپی URL",
|
||||||
|
"urlCopied": "URL کپی شد",
|
||||||
|
"deleteConfirm": "حذف صفحه فرود «{{title}}»؟",
|
||||||
|
"created": "صفحه فرود ایجاد شد",
|
||||||
|
"updated": "صفحه فرود بهروز شد",
|
||||||
|
"deleted": "صفحه فرود حذف شد",
|
||||||
|
"saveOrder": "ذخیره ترتیب",
|
||||||
|
"orderSaved": "ترتیب ذخیره شد",
|
||||||
|
"general": "عمومی",
|
||||||
|
"content": "محتوا",
|
||||||
|
"save": "ذخیره",
|
||||||
|
"back": "بازگشت",
|
||||||
|
"invalidSlug": "Slug \u0641\u0642\u0637 \u0645\u06cc\u200c\u062a\u0648\u0627\u0646\u062f \u0634\u0627\u0645\u0644 \u062d\u0631\u0648\u0641 \u06a9\u0648\u0686\u06a9\u060c \u0627\u0639\u062f\u0627\u062f \u0648 \u062e\u0637 \u062a\u06cc\u0631\u0647 \u0628\u0627\u0634\u062f",
|
||||||
|
"titleRequired": "\u0639\u0646\u0648\u0627\u0646 \u0627\u0644\u0632\u0627\u0645\u06cc \u0627\u0633\u062a",
|
||||||
|
"noTariffs": "\u062d\u062f\u0627\u0642\u0644 \u06cc\u06a9 \u062a\u0639\u0631\u0641\u0647 \u0627\u0646\u062a\u062e\u0627\u0628 \u06a9\u0646\u06cc\u062f",
|
||||||
|
"noPaymentMethods": "\u062d\u062f\u0627\u0642\u0644 \u06cc\u06a9 \u0631\u0648\u0634 \u067e\u0631\u062f\u0627\u062e\u062a \u0627\u0636\u0627\u0641\u0647 \u06a9\u0646\u06cc\u062f",
|
||||||
|
"selectMethods": "انتخاب روشهای پرداخت موجود",
|
||||||
|
"noSystemMethods": "هیچ روش پرداختی در سیستم پیکربندی نشده است",
|
||||||
|
"methodOrder": "برای تغییر ترتیب بکشید",
|
||||||
|
"methodSubOptions": "گزینههای فرعی پرداخت",
|
||||||
|
"loadingPeriods": "در حال بارگذاری...",
|
||||||
|
"periodDaySuffix": "روز",
|
||||||
|
"localeTab": "زبان",
|
||||||
|
"localeHint": "زبان را تغییر دهید تا متن را به آن زبان ویرایش کنید"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"adminUpdates": {
|
"adminUpdates": {
|
||||||
@@ -3416,5 +3494,81 @@
|
|||||||
"error": "ادغام ناموفق بود. لطفاً بعداً دوباره تلاش کنید.",
|
"error": "ادغام ناموفق بود. لطفاً بعداً دوباره تلاش کنید.",
|
||||||
"expiresIn": "{{minutes}} دقیقه باقی مانده",
|
"expiresIn": "{{minutes}} دقیقه باقی مانده",
|
||||||
"merging": "در حال ادغام..."
|
"merging": "در حال ادغام..."
|
||||||
|
},
|
||||||
|
"landing": {
|
||||||
|
"notFound": "صفحه یافت نشد",
|
||||||
|
"forMe": "برای خودم",
|
||||||
|
"asGift": "به عنوان هدیه",
|
||||||
|
"contactLabel": "ایمیل یا @نام کاربری شما",
|
||||||
|
"yourContact": "ایمیل یا @نام کاربری شما",
|
||||||
|
"contactPlaceholder": "email@example.com یا @username",
|
||||||
|
"contactHint": "ایمیل یا @نام کاربری تلگرام را وارد کنید",
|
||||||
|
"recipientLabel": "گیرنده هدیه",
|
||||||
|
"recipientPlaceholder": "ایمیل یا @نام کاربری گیرنده",
|
||||||
|
"giftMessageLabel": "پیام تبریک (اختیاری)",
|
||||||
|
"giftMessagePlaceholder": "یک پیام تبریک بنویسید...",
|
||||||
|
"chooseTariff": "یک طرح انتخاب کنید",
|
||||||
|
"devices": "دستگاه",
|
||||||
|
"paymentMethod": "روش پرداخت",
|
||||||
|
"processing": "در حال پردازش...",
|
||||||
|
"payButton": "پرداخت {{price}}",
|
||||||
|
"accessFor": "دسترسی برای {{period}}",
|
||||||
|
"purchaseError": "خطا در ایجاد پرداخت",
|
||||||
|
"purchaseNotFound": "خرید یافت نشد",
|
||||||
|
"awaitingPayment": "در انتظار پرداخت",
|
||||||
|
"awaitingPaymentDesc": "پرداخت را در پنجره باز شده تکمیل کنید",
|
||||||
|
"purchaseSuccess": "پرداخت موفق بود!",
|
||||||
|
"keySentTo": "کلید اشتراک به {{contact}} ارسال شد",
|
||||||
|
"giftSentTo": "هدیه به {{contact}} ارسال شد",
|
||||||
|
"purchaseFailed": "پرداخت ناموفق",
|
||||||
|
"purchaseFailedDesc": "پرداخت انجام نشد. دوباره تلاش کنید.",
|
||||||
|
"subscriptionLink": "لینک اشتراک",
|
||||||
|
"daysAccess": "روز دسترسی",
|
||||||
|
"error": "خطا",
|
||||||
|
"gb": "گیگابایت",
|
||||||
|
"selectedTariff": "تعرفه",
|
||||||
|
"period": "دوره",
|
||||||
|
"total": "مجموع",
|
||||||
|
"pay": "پرداخت",
|
||||||
|
"choosePeriod": "انتخاب دوره",
|
||||||
|
"copy": "کپی",
|
||||||
|
"copied": "کپی شد!",
|
||||||
|
"copyLink": "کپی لینک",
|
||||||
|
"giftToggleLabel": "\u0646\u0648\u0639 \u062e\u0631\u06cc\u062f",
|
||||||
|
"pollTimedOut": "\u0632\u0645\u0627\u0646 \u0628\u06cc\u0634\u062a\u0631\u06cc \u0637\u0648\u0644 \u06a9\u0634\u06cc\u062f",
|
||||||
|
"pollTimedOutDesc": "\u067e\u0631\u062f\u0627\u0632\u0634 \u067e\u0631\u062f\u0627\u062e\u062a \u0628\u06cc\u0634\u062a\u0631 \u0627\u0632 \u062d\u062f \u0645\u0639\u0645\u0648\u0644 \u0637\u0648\u0644 \u06a9\u0634\u06cc\u062f\u0647 \u0627\u0633\u062a. \u0645\u06cc\u200c\u062a\u0648\u0627\u0646\u06cc\u062f \u062f\u0648\u0628\u0627\u0631\u0647 \u0628\u0631\u0631\u0633\u06cc \u06a9\u0646\u06cc\u062f.",
|
||||||
|
"pendingActivation": "اشتراک آماده فعالسازی",
|
||||||
|
"pendingActivationDesc": "شما قبلاً اشتراک فعالی دارید. فعالسازی اشتراک جدید جایگزین اشتراک فعلی میشود.",
|
||||||
|
"activateNow": "فعالسازی",
|
||||||
|
"activating": "در حال فعالسازی...",
|
||||||
|
"activationFailed": "خطا در فعالسازی",
|
||||||
|
"giftMessage": "پیام",
|
||||||
|
"cabinetReady": "حساب شما آماده است",
|
||||||
|
"cabinetEmail": "ایمیل",
|
||||||
|
"cabinetPassword": "رمز عبور",
|
||||||
|
"goToCabinet": "رفتن به پنل کاربری",
|
||||||
|
"saveCredentials": "این اطلاعات ورود را ذخیره کنید",
|
||||||
|
"credentialsSentToEmail": "اطلاعات ورود به ایمیل شما ارسال شد",
|
||||||
|
"giftSentSuccess": "هدیه ارسال شد!",
|
||||||
|
"giftSentDesc": "گیرنده از طریق ایمیل مطلع خواهد شد",
|
||||||
|
"giftPendingActivationDesc": "گیرنده در حال حاضر اشتراک فعال دارد. لینک فعالسازی هدیه برای او ارسال خواهد شد.",
|
||||||
|
"autoLoginFailed": "ورود خودکار ناموفق بود",
|
||||||
|
"autoLoginProcessing": "در حال ورود...",
|
||||||
|
"periodLabels": {
|
||||||
|
"d1": "۱ روز",
|
||||||
|
"d2": "۲ روز",
|
||||||
|
"d3": "۳ روز",
|
||||||
|
"d5": "۵ روز",
|
||||||
|
"d7": "۱ هفته",
|
||||||
|
"d14": "۲ هفته",
|
||||||
|
"d30": "۱ ماه",
|
||||||
|
"d60": "۲ ماه",
|
||||||
|
"d90": "۳ ماه",
|
||||||
|
"d180": "۶ ماه",
|
||||||
|
"d365": "۱ سال",
|
||||||
|
"d456": "۱ سال و ۳ ماه",
|
||||||
|
"nDays": "{{count}} روز",
|
||||||
|
"nMonths": "{{count}} ماه"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,13 +247,13 @@
|
|||||||
"connectDevice": "Подключить устройство",
|
"connectDevice": "Подключить устройство",
|
||||||
"devicesConnected": "{{count}} подключено",
|
"devicesConnected": "{{count}} подключено",
|
||||||
"devicesOfMax": "{{used}} из {{max}} подключено",
|
"devicesOfMax": "{{used}} из {{max}} подключено",
|
||||||
|
"devicesConnectedUnlimited": "{{used}} подключено · без лимита",
|
||||||
"devicesShort": "устр.",
|
"devicesShort": "устр.",
|
||||||
"trafficUsage": "{{used}} / {{limit}} ГБ",
|
"trafficUsage": "{{used}} / {{limit}} ГБ",
|
||||||
"trafficUsageTitle": "Расход трафика",
|
"trafficUsageTitle": "Расход трафика",
|
||||||
"usedTraffic": "использовано {{amount}}",
|
"usedTraffic": "использовано {{amount}}",
|
||||||
"usedSuffix": "израсходовано",
|
"usedSuffix": "израсходовано",
|
||||||
"unlimited": "Безлимит",
|
"unlimited": "Безлимит",
|
||||||
"unlimitedTraffic": "Безлимитный трафик",
|
|
||||||
"tariff": "Тариф",
|
"tariff": "Тариф",
|
||||||
"validUntil": "до {{date}}",
|
"validUntil": "до {{date}}",
|
||||||
"remaining": "Осталось",
|
"remaining": "Осталось",
|
||||||
@@ -1064,7 +1064,8 @@
|
|||||||
"roleAssign": "Назначение ролей",
|
"roleAssign": "Назначение ролей",
|
||||||
"policies": "Политики доступа",
|
"policies": "Политики доступа",
|
||||||
"auditLog": "Журнал аудита",
|
"auditLog": "Журнал аудита",
|
||||||
"salesStats": "Статистика продаж"
|
"salesStats": "Статистика продаж",
|
||||||
|
"landings": "Лендинги"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "Панель администратора",
|
"title": "Панель администратора",
|
||||||
@@ -1097,7 +1098,8 @@
|
|||||||
"roleAssignDesc": "Назначение и отзыв ролей пользователей",
|
"roleAssignDesc": "Назначение и отзыв ролей пользователей",
|
||||||
"policiesDesc": "Настройка политик контроля доступа ABAC",
|
"policiesDesc": "Настройка политик контроля доступа ABAC",
|
||||||
"auditLogDesc": "Просмотр активности системы и истории доступа",
|
"auditLogDesc": "Просмотр активности системы и истории доступа",
|
||||||
"salesStatsDesc": "Аналитика продаж и тренды"
|
"salesStatsDesc": "Аналитика продаж и тренды",
|
||||||
|
"landingsDesc": "Страницы быстрой покупки"
|
||||||
},
|
},
|
||||||
"salesStats": {
|
"salesStats": {
|
||||||
"title": "Статистика продаж",
|
"title": "Статистика продаж",
|
||||||
@@ -1760,6 +1762,10 @@
|
|||||||
"Subscription Show Devices": "Показывать устройства",
|
"Subscription Show Devices": "Показывать устройства",
|
||||||
"Telegram Stars Enabled": "Stars включены",
|
"Telegram Stars Enabled": "Stars включены",
|
||||||
"Telegram Stars Rate Rub": "Курс Stars (₽)",
|
"Telegram Stars Rate Rub": "Курс Stars (₽)",
|
||||||
|
"Telegram Widget Size": "Размер виджета",
|
||||||
|
"Telegram Widget Radius": "Скругление углов",
|
||||||
|
"Telegram Widget Userpic": "Показывать аватар",
|
||||||
|
"Telegram Widget Request Access": "Запрос доступа",
|
||||||
"Tribute Api Key": "API ключ",
|
"Tribute Api Key": "API ключ",
|
||||||
"Tribute Donate Link": "Ссылка доната",
|
"Tribute Donate Link": "Ссылка доната",
|
||||||
"Tribute Enabled": "Tribute включен",
|
"Tribute Enabled": "Tribute включен",
|
||||||
@@ -2148,7 +2154,10 @@
|
|||||||
"External Admin Token": "Токен внешней админки",
|
"External Admin Token": "Токен внешней админки",
|
||||||
"External Admin Token Bot Id": "ID бота",
|
"External Admin Token Bot Id": "ID бота",
|
||||||
"Bot Username": "Username бота",
|
"Bot Username": "Username бота",
|
||||||
"Debug": "Отладка"
|
"Debug": "Отладка",
|
||||||
|
"telegramOidcEnabled": "OIDC включён",
|
||||||
|
"telegramOidcClientId": "Client ID (ID бота)",
|
||||||
|
"telegramOidcClientSecret": "Client Secret"
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"PAYMENT": "Платежи",
|
"PAYMENT": "Платежи",
|
||||||
@@ -2162,6 +2171,8 @@
|
|||||||
"PAL24": "PAL24",
|
"PAL24": "PAL24",
|
||||||
"WATA": "Wata",
|
"WATA": "Wata",
|
||||||
"TELEGRAM": "Telegram",
|
"TELEGRAM": "Telegram",
|
||||||
|
"TELEGRAM_WIDGET": "Telegram Login Widget",
|
||||||
|
"TELEGRAM_OIDC": "Telegram Login (OIDC)",
|
||||||
"SUBSCRIPTIONS_CORE": "Основные",
|
"SUBSCRIPTIONS_CORE": "Основные",
|
||||||
"SIMPLE_SUBSCRIPTION": "Простая подписка",
|
"SIMPLE_SUBSCRIPTION": "Простая подписка",
|
||||||
"PERIODS": "Периоды",
|
"PERIODS": "Периоды",
|
||||||
@@ -2426,6 +2437,10 @@
|
|||||||
"noPeriodsHint": "Нет добавленных периодов. Добавьте хотя бы один период.",
|
"noPeriodsHint": "Нет добавленных периодов. Добавьте хотя бы один период.",
|
||||||
"daysShort": "дн.",
|
"daysShort": "дн.",
|
||||||
"serversTitle": "Серверы",
|
"serversTitle": "Серверы",
|
||||||
|
"externalSquadTitle": "Внешний сквад",
|
||||||
|
"externalSquadHint": "Назначьте внешний сквад RemnaWave для пользователей этого тарифа. При создании подписки пользователь будет автоматически добавлен в выбранный сквад.",
|
||||||
|
"noExternalSquad": "Без внешнего сквада",
|
||||||
|
"externalSquadUsers": "пользователей",
|
||||||
"serversTabHint": "Выберите серверы, доступные на этом тарифе.",
|
"serversTabHint": "Выберите серверы, доступные на этом тарифе.",
|
||||||
"noServersAvailable": "Нет доступных серверов",
|
"noServersAvailable": "Нет доступных серверов",
|
||||||
"promoGroupsTitle": "Промо-группы",
|
"promoGroupsTitle": "Промо-группы",
|
||||||
@@ -2530,6 +2545,7 @@
|
|||||||
"subtitle": "Управление рекламными ссылками",
|
"subtitle": "Управление рекламными ссылками",
|
||||||
"createButton": "Создать",
|
"createButton": "Создать",
|
||||||
"noData": "Нет рекламных кампаний",
|
"noData": "Нет рекламных кампаний",
|
||||||
|
"loadMore": "Загрузить ещё",
|
||||||
"bonusType": {
|
"bonusType": {
|
||||||
"balance": "Баланс",
|
"balance": "Баланс",
|
||||||
"subscription": "Подписка",
|
"subscription": "Подписка",
|
||||||
@@ -3414,6 +3430,7 @@
|
|||||||
"apps": "Приложения",
|
"apps": "Приложения",
|
||||||
"email_templates": "Шаблоны писем",
|
"email_templates": "Шаблоны писем",
|
||||||
"pinned_messages": "Закреплённые сообщения",
|
"pinned_messages": "Закреплённые сообщения",
|
||||||
|
"landings": "Лендинги",
|
||||||
"updates": "Обновления"
|
"updates": "Обновления"
|
||||||
},
|
},
|
||||||
"permissionActions": {
|
"permissionActions": {
|
||||||
@@ -3629,6 +3646,7 @@
|
|||||||
"filters": {
|
"filters": {
|
||||||
"title": "Фильтры",
|
"title": "Фильтры",
|
||||||
"active": "Активны",
|
"active": "Активны",
|
||||||
|
"user": "Пользователь",
|
||||||
"action": "Действие",
|
"action": "Действие",
|
||||||
"actionPlaceholder": "Поиск по действию...",
|
"actionPlaceholder": "Поиск по действию...",
|
||||||
"resource": "Тип ресурса",
|
"resource": "Тип ресурса",
|
||||||
@@ -3693,6 +3711,74 @@
|
|||||||
"loadFailed": "Не удалось загрузить журнал аудита",
|
"loadFailed": "Не удалось загрузить журнал аудита",
|
||||||
"retry": "Попробовать снова"
|
"retry": "Попробовать снова"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"landings": {
|
||||||
|
"title": "Лендинги",
|
||||||
|
"create": "Создать лендинг",
|
||||||
|
"edit": "Редактировать",
|
||||||
|
"slug": "URL-идентификатор",
|
||||||
|
"slugHint": "Латиница, цифры и дефис",
|
||||||
|
"pageTitle": "Заголовок",
|
||||||
|
"subtitle": "Подзаголовок",
|
||||||
|
"footerText": "Текст внизу (HTML)",
|
||||||
|
"features": "Преимущества",
|
||||||
|
"addFeature": "Добавить",
|
||||||
|
"featureIcon": "Иконка",
|
||||||
|
"featureTitle": "Заголовок",
|
||||||
|
"featureDesc": "Описание",
|
||||||
|
"tariffs": "Тарифы",
|
||||||
|
"selectTariffs": "Выберите тарифы",
|
||||||
|
"periods": "Периоды",
|
||||||
|
"paymentMethods": "Способы оплаты",
|
||||||
|
"addMethod": "Добавить метод",
|
||||||
|
"methodId": "ID метода",
|
||||||
|
"methodName": "Название",
|
||||||
|
"methodDesc": "Описание",
|
||||||
|
"methodIcon": "URL иконки",
|
||||||
|
"gifts": "Подарки",
|
||||||
|
"giftEnabled": "Покупка в подарок",
|
||||||
|
"customCss": "CSS",
|
||||||
|
"seo": "SEO",
|
||||||
|
"metaTitle": "Meta Title",
|
||||||
|
"metaDesc": "Meta Description",
|
||||||
|
"active": "Активен",
|
||||||
|
"inactive": "Неактивен",
|
||||||
|
"purchases": "покупок",
|
||||||
|
"copyUrl": "Скопировать URL",
|
||||||
|
"urlCopied": "URL скопирован",
|
||||||
|
"deleteConfirm": "Удалить лендинг «{{title}}»?",
|
||||||
|
"created": "Лендинг создан",
|
||||||
|
"updated": "Лендинг обновлён",
|
||||||
|
"deleted": "Лендинг удалён",
|
||||||
|
"saveOrder": "Сохранить порядок",
|
||||||
|
"orderSaved": "Порядок сохранён",
|
||||||
|
"general": "Основное",
|
||||||
|
"content": "Контент",
|
||||||
|
"save": "Сохранить",
|
||||||
|
"back": "Назад",
|
||||||
|
"invalidSlug": "Slug может содержать только строчные буквы, цифры и дефисы",
|
||||||
|
"titleRequired": "Заголовок обязателен",
|
||||||
|
"noTariffs": "Выберите хотя бы один тариф",
|
||||||
|
"noPaymentMethods": "Добавьте хотя бы один способ оплаты",
|
||||||
|
"selectMethods": "Выберите доступные методы",
|
||||||
|
"noSystemMethods": "В системе не настроено ни одного способа оплаты",
|
||||||
|
"methodOrder": "Перетащите для изменения порядка",
|
||||||
|
"methodSubOptions": "Варианты оплаты",
|
||||||
|
"loadingPeriods": "Загрузка...",
|
||||||
|
"periodDaySuffix": "д",
|
||||||
|
"localeTab": "Язык",
|
||||||
|
"localeHint": "Переключите язык для редактирования текста на этом языке",
|
||||||
|
"discount": "Скидка",
|
||||||
|
"discountEnabled": "Включить скидку",
|
||||||
|
"discountPercent": "Скидка %",
|
||||||
|
"discountStartsAt": "Дата начала",
|
||||||
|
"discountEndsAt": "Дата окончания",
|
||||||
|
"discountBadge": "Текст баннера (необязательно)",
|
||||||
|
"discountBadgePlaceholder": "напр. Весенняя распродажа!",
|
||||||
|
"discountOverrides": "Индивидуальные скидки по тарифам",
|
||||||
|
"discountOverridesHint": "Оставьте пустым для использования общей скидки",
|
||||||
|
"discountPreview": "Предпросмотр",
|
||||||
|
"discountActive": "Скидка"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"adminUpdates": {
|
"adminUpdates": {
|
||||||
@@ -4428,5 +4514,91 @@
|
|||||||
"error": "Ошибка при объединении. Попробуйте позже.",
|
"error": "Ошибка при объединении. Попробуйте позже.",
|
||||||
"expiresIn": "Действует ещё {{minutes}}",
|
"expiresIn": "Действует ещё {{minutes}}",
|
||||||
"merging": "Объединение..."
|
"merging": "Объединение..."
|
||||||
|
},
|
||||||
|
"landing": {
|
||||||
|
"notFound": "Страница не найдена",
|
||||||
|
"forMe": "Для себя",
|
||||||
|
"asGift": "В подарок",
|
||||||
|
"contactLabel": "Ваш email или @username Telegram",
|
||||||
|
"yourContact": "Ваш email или @username",
|
||||||
|
"contactPlaceholder": "email@example.com или @username",
|
||||||
|
"contactHint": "Введите email или Telegram @username",
|
||||||
|
"recipientLabel": "Кому подарить",
|
||||||
|
"recipientPlaceholder": "email или @username получателя",
|
||||||
|
"giftMessageLabel": "Поздравление (необязательно)",
|
||||||
|
"giftMessagePlaceholder": "Напишите поздравление...",
|
||||||
|
"chooseTariff": "Выберите тариф",
|
||||||
|
"devices": "устройств",
|
||||||
|
"paymentMethod": "Способ оплаты",
|
||||||
|
"processing": "Обработка...",
|
||||||
|
"payButton": "Оплатить {{price}}",
|
||||||
|
"accessFor": "Доступ на {{period}}",
|
||||||
|
"purchaseError": "Ошибка при создании оплаты",
|
||||||
|
"purchaseNotFound": "Покупка не найдена",
|
||||||
|
"awaitingPayment": "Ожидание оплаты",
|
||||||
|
"awaitingPaymentDesc": "Завершите оплату в открывшемся окне",
|
||||||
|
"purchaseSuccess": "Оплата прошла успешно!",
|
||||||
|
"keySentTo": "Ключ подписки отправлен на {{contact}}",
|
||||||
|
"giftSentTo": "Подарок отправлен на {{contact}}",
|
||||||
|
"purchaseFailed": "Ошибка оплаты",
|
||||||
|
"purchaseFailedDesc": "Оплата не прошла. Попробуйте снова.",
|
||||||
|
"subscriptionLink": "Ссылка подписки",
|
||||||
|
"daysAccess": "дней доступа",
|
||||||
|
"error": "Ошибка",
|
||||||
|
"gb": "ГБ",
|
||||||
|
"selectedTariff": "Тариф",
|
||||||
|
"period": "Период",
|
||||||
|
"total": "Итого",
|
||||||
|
"pay": "Оплатить",
|
||||||
|
"choosePeriod": "Выберите период",
|
||||||
|
"copy": "Скопировать",
|
||||||
|
"copied": "Скопировано!",
|
||||||
|
"copyLink": "Скопировать ссылку",
|
||||||
|
"giftToggleLabel": "Тип покупки",
|
||||||
|
"pollTimedOut": "Занимает больше времени, чем ожидалось",
|
||||||
|
"pollTimedOutDesc": "Обработка платежа занимает больше времени, чем обычно. Вы можете попробовать проверить ещё раз.",
|
||||||
|
"pendingActivation": "Подписка готова к активации",
|
||||||
|
"pendingActivationDesc": "У вас уже есть активная подписка. Активация новой заменит текущую.",
|
||||||
|
"activateNow": "Активировать",
|
||||||
|
"activating": "Активация...",
|
||||||
|
"activationFailed": "Ошибка активации",
|
||||||
|
"giftMessage": "Сообщение",
|
||||||
|
"cabinetReady": "Ваш аккаунт готов",
|
||||||
|
"cabinetEmail": "Email",
|
||||||
|
"cabinetPassword": "Пароль",
|
||||||
|
"goToCabinet": "Перейти в кабинет",
|
||||||
|
"saveCredentials": "Сохраните эти данные для входа",
|
||||||
|
"credentialsSentToEmail": "Данные для входа отправлены на email",
|
||||||
|
"giftSentSuccess": "Подарок отправлен!",
|
||||||
|
"giftSentDesc": "Получатель получит уведомление на email",
|
||||||
|
"giftPendingActivationDesc": "У получателя уже есть активная подписка. Ему будет отправлена ссылка для активации подарка.",
|
||||||
|
"autoLoginFailed": "Не удалось выполнить автоматический вход",
|
||||||
|
"autoLoginProcessing": "Выполняется вход...",
|
||||||
|
"discount": {
|
||||||
|
"days": "д",
|
||||||
|
"hours": "ч",
|
||||||
|
"minutes": "м",
|
||||||
|
"seconds": "с"
|
||||||
|
},
|
||||||
|
"periodLabels": {
|
||||||
|
"d1": "1 день",
|
||||||
|
"d2": "2 дня",
|
||||||
|
"d3": "3 дня",
|
||||||
|
"d5": "5 дней",
|
||||||
|
"d7": "1 неделя",
|
||||||
|
"d14": "2 недели",
|
||||||
|
"d30": "1 месяц",
|
||||||
|
"d60": "2 месяца",
|
||||||
|
"d90": "3 месяца",
|
||||||
|
"d180": "6 месяцев",
|
||||||
|
"d365": "1 год",
|
||||||
|
"d456": "1 год + 3 мес.",
|
||||||
|
"nDays_one": "{{count}} день",
|
||||||
|
"nDays_few": "{{count}} дня",
|
||||||
|
"nDays_many": "{{count}} дней",
|
||||||
|
"nMonths_one": "{{count}} месяц",
|
||||||
|
"nMonths_few": "{{count}} месяца",
|
||||||
|
"nMonths_many": "{{count}} месяцев"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -866,7 +866,8 @@
|
|||||||
"roleAssign": "角色分配",
|
"roleAssign": "角色分配",
|
||||||
"policies": "访问策略",
|
"policies": "访问策略",
|
||||||
"auditLog": "审计日志",
|
"auditLog": "审计日志",
|
||||||
"salesStats": "销售统计"
|
"salesStats": "销售统计",
|
||||||
|
"landings": "落地页"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "管理面板",
|
"title": "管理面板",
|
||||||
@@ -899,7 +900,8 @@
|
|||||||
"roleAssignDesc": "分配和撤销用户角色",
|
"roleAssignDesc": "分配和撤销用户角色",
|
||||||
"policiesDesc": "配置ABAC访问控制策略",
|
"policiesDesc": "配置ABAC访问控制策略",
|
||||||
"auditLogDesc": "查看系统活动和访问历史",
|
"auditLogDesc": "查看系统活动和访问历史",
|
||||||
"salesStatsDesc": "销售分析与趋势"
|
"salesStatsDesc": "销售分析与趋势",
|
||||||
|
"landingsDesc": "快速购买落地页"
|
||||||
},
|
},
|
||||||
"salesStats": {
|
"salesStats": {
|
||||||
"title": "销售统计",
|
"title": "销售统计",
|
||||||
@@ -1377,7 +1379,20 @@
|
|||||||
"projectName": "项目名称",
|
"projectName": "项目名称",
|
||||||
"quickPresets": "快速预设",
|
"quickPresets": "快速预设",
|
||||||
"resetAllColors": "重置所有颜色",
|
"resetAllColors": "重置所有颜色",
|
||||||
"statusColors": "状态颜色"
|
"statusColors": "状态颜色",
|
||||||
|
"categories": {
|
||||||
|
"TELEGRAM_WIDGET": "Telegram Login Widget",
|
||||||
|
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
|
||||||
|
},
|
||||||
|
"settingNames": {
|
||||||
|
"Telegram Widget Size": "小部件大小",
|
||||||
|
"Telegram Widget Radius": "圆角半径",
|
||||||
|
"Telegram Widget Userpic": "显示头像",
|
||||||
|
"Telegram Widget Request Access": "请求访问权限",
|
||||||
|
"telegramOidcEnabled": "启用 OIDC",
|
||||||
|
"telegramOidcClientId": "Client ID(机器人 ID)",
|
||||||
|
"telegramOidcClientSecret": "Client Secret"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"color": "按钮颜色",
|
"color": "按钮颜色",
|
||||||
@@ -1589,6 +1604,10 @@
|
|||||||
"noPeriodsHint": "没有添加的周期。请至少添加一个周期。",
|
"noPeriodsHint": "没有添加的周期。请至少添加一个周期。",
|
||||||
"daysShort": "天",
|
"daysShort": "天",
|
||||||
"serversTitle": "服务器",
|
"serversTitle": "服务器",
|
||||||
|
"externalSquadTitle": "外部小组",
|
||||||
|
"externalSquadHint": "为此套餐的用户分配RemnaWave外部小组。创建订阅时,用户将自动添加到所选小组。",
|
||||||
|
"noExternalSquad": "无外部小组",
|
||||||
|
"externalSquadUsers": "用户",
|
||||||
"serversTabHint": "选择此套餐上可用的服务器。",
|
"serversTabHint": "选择此套餐上可用的服务器。",
|
||||||
"noServersAvailable": "没有可用的服务器",
|
"noServersAvailable": "没有可用的服务器",
|
||||||
"promoGroupsTitle": "促销组",
|
"promoGroupsTitle": "促销组",
|
||||||
@@ -2598,6 +2617,7 @@
|
|||||||
"apps": "应用",
|
"apps": "应用",
|
||||||
"email_templates": "邮件模板",
|
"email_templates": "邮件模板",
|
||||||
"pinned_messages": "置顶消息",
|
"pinned_messages": "置顶消息",
|
||||||
|
"landings": "落地页",
|
||||||
"updates": "更新"
|
"updates": "更新"
|
||||||
},
|
},
|
||||||
"permissionActions": {
|
"permissionActions": {
|
||||||
@@ -2805,6 +2825,7 @@
|
|||||||
"filters": {
|
"filters": {
|
||||||
"title": "筛选",
|
"title": "筛选",
|
||||||
"active": "已激活",
|
"active": "已激活",
|
||||||
|
"user": "用户",
|
||||||
"action": "操作",
|
"action": "操作",
|
||||||
"actionPlaceholder": "按操作搜索...",
|
"actionPlaceholder": "按操作搜索...",
|
||||||
"resource": "资源类型",
|
"resource": "资源类型",
|
||||||
@@ -2863,6 +2884,63 @@
|
|||||||
"loadFailed": "加载审计日志失败",
|
"loadFailed": "加载审计日志失败",
|
||||||
"retry": "重试"
|
"retry": "重试"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"landings": {
|
||||||
|
"title": "落地页",
|
||||||
|
"create": "创建落地页",
|
||||||
|
"edit": "编辑",
|
||||||
|
"slug": "URL标识符",
|
||||||
|
"slugHint": "小写字母、数字和连字符",
|
||||||
|
"pageTitle": "标题",
|
||||||
|
"subtitle": "副标题",
|
||||||
|
"footerText": "底部文本 (HTML)",
|
||||||
|
"features": "功能特点",
|
||||||
|
"addFeature": "添加",
|
||||||
|
"featureIcon": "图标",
|
||||||
|
"featureTitle": "标题",
|
||||||
|
"featureDesc": "描述",
|
||||||
|
"tariffs": "套餐",
|
||||||
|
"selectTariffs": "选择套餐",
|
||||||
|
"periods": "周期",
|
||||||
|
"paymentMethods": "支付方式",
|
||||||
|
"addMethod": "添加方式",
|
||||||
|
"methodId": "方式ID",
|
||||||
|
"methodName": "名称",
|
||||||
|
"methodDesc": "描述",
|
||||||
|
"methodIcon": "图标URL",
|
||||||
|
"gifts": "礼物",
|
||||||
|
"giftEnabled": "礼物购买",
|
||||||
|
"customCss": "CSS",
|
||||||
|
"seo": "SEO",
|
||||||
|
"metaTitle": "Meta标题",
|
||||||
|
"metaDesc": "Meta描述",
|
||||||
|
"active": "已激活",
|
||||||
|
"inactive": "未激活",
|
||||||
|
"purchases": "次购买",
|
||||||
|
"copyUrl": "复制URL",
|
||||||
|
"urlCopied": "URL已复制",
|
||||||
|
"deleteConfirm": "删除落地页「{{title}}」?",
|
||||||
|
"created": "落地页已创建",
|
||||||
|
"updated": "落地页已更新",
|
||||||
|
"deleted": "落地页已删除",
|
||||||
|
"saveOrder": "保存排序",
|
||||||
|
"orderSaved": "排序已保存",
|
||||||
|
"general": "基本设置",
|
||||||
|
"content": "内容",
|
||||||
|
"save": "保存",
|
||||||
|
"back": "返回",
|
||||||
|
"invalidSlug": "Slug\u53ea\u80fd\u5305\u542b\u5c0f\u5199\u5b57\u6bcd\u3001\u6570\u5b57\u548c\u8fde\u5b57\u7b26",
|
||||||
|
"titleRequired": "\u6807\u9898\u4e3a\u5fc5\u586b\u9879",
|
||||||
|
"noTariffs": "\u8bf7\u81f3\u5c11\u9009\u62e9\u4e00\u4e2a\u5957\u9910",
|
||||||
|
"noPaymentMethods": "\u8bf7\u81f3\u5c11\u6dfb\u52a0\u4e00\u4e2a\u652f\u4ed8\u65b9\u5f0f",
|
||||||
|
"selectMethods": "选择可用的支付方式",
|
||||||
|
"noSystemMethods": "系统中未配置任何支付方式",
|
||||||
|
"methodOrder": "拖动以调整顺序",
|
||||||
|
"methodSubOptions": "支付子选项",
|
||||||
|
"loadingPeriods": "加载中...",
|
||||||
|
"periodDaySuffix": "天",
|
||||||
|
"localeTab": "语言",
|
||||||
|
"localeHint": "切换语言以编辑该语言的文本"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"adminUpdates": {
|
"adminUpdates": {
|
||||||
@@ -3415,5 +3493,81 @@
|
|||||||
"error": "合并失败,请稍后重试。",
|
"error": "合并失败,请稍后重试。",
|
||||||
"expiresIn": "剩余 {{minutes}} 分钟",
|
"expiresIn": "剩余 {{minutes}} 分钟",
|
||||||
"merging": "合并中..."
|
"merging": "合并中..."
|
||||||
|
},
|
||||||
|
"landing": {
|
||||||
|
"notFound": "页面未找到",
|
||||||
|
"forMe": "为自己",
|
||||||
|
"asGift": "作为礼物",
|
||||||
|
"contactLabel": "您的邮箱或 @用户名",
|
||||||
|
"yourContact": "您的邮箱或 @用户名",
|
||||||
|
"contactPlaceholder": "email@example.com 或 @username",
|
||||||
|
"contactHint": "输入邮箱或 Telegram @用户名",
|
||||||
|
"recipientLabel": "赠送对象",
|
||||||
|
"recipientPlaceholder": "收件人邮箱或 @用户名",
|
||||||
|
"giftMessageLabel": "祝福语(可选)",
|
||||||
|
"giftMessagePlaceholder": "写一段祝福语...",
|
||||||
|
"chooseTariff": "选择套餐",
|
||||||
|
"devices": "设备",
|
||||||
|
"paymentMethod": "支付方式",
|
||||||
|
"processing": "处理中...",
|
||||||
|
"payButton": "支付 {{price}}",
|
||||||
|
"accessFor": "{{period}} 访问权限",
|
||||||
|
"purchaseError": "创建支付失败",
|
||||||
|
"purchaseNotFound": "未找到购买记录",
|
||||||
|
"awaitingPayment": "等待支付",
|
||||||
|
"awaitingPaymentDesc": "请在打开的窗口中完成支付",
|
||||||
|
"purchaseSuccess": "支付成功!",
|
||||||
|
"keySentTo": "订阅密钥已发送至 {{contact}}",
|
||||||
|
"giftSentTo": "礼物已发送至 {{contact}}",
|
||||||
|
"purchaseFailed": "支付失败",
|
||||||
|
"purchaseFailedDesc": "支付未成功,请重试。",
|
||||||
|
"subscriptionLink": "订阅链接",
|
||||||
|
"daysAccess": "天访问权限",
|
||||||
|
"error": "错误",
|
||||||
|
"gb": "GB",
|
||||||
|
"selectedTariff": "套餐",
|
||||||
|
"period": "时长",
|
||||||
|
"total": "总计",
|
||||||
|
"pay": "支付",
|
||||||
|
"choosePeriod": "选择时长",
|
||||||
|
"copy": "复制",
|
||||||
|
"copied": "已复制!",
|
||||||
|
"copyLink": "复制链接",
|
||||||
|
"giftToggleLabel": "\u8d2d\u4e70\u7c7b\u578b",
|
||||||
|
"pollTimedOut": "\u5904\u7406\u65f6\u95f4\u8f83\u957f",
|
||||||
|
"pollTimedOutDesc": "\u4ed8\u6b3e\u5904\u7406\u65f6\u95f4\u6bd4\u5e73\u65f6\u957f\u3002\u60a8\u53ef\u4ee5\u5c1d\u8bd5\u518d\u6b21\u68c0\u67e5\u3002",
|
||||||
|
"pendingActivation": "订阅准备激活",
|
||||||
|
"pendingActivationDesc": "您已有活跃订阅。激活新订阅将替换当前订阅。",
|
||||||
|
"activateNow": "立即激活",
|
||||||
|
"activating": "激活中...",
|
||||||
|
"activationFailed": "激活失败",
|
||||||
|
"giftMessage": "留言",
|
||||||
|
"cabinetReady": "您的账户已准备就绪",
|
||||||
|
"cabinetEmail": "邮箱",
|
||||||
|
"cabinetPassword": "密码",
|
||||||
|
"goToCabinet": "前往个人中心",
|
||||||
|
"saveCredentials": "请保存这些登录信息",
|
||||||
|
"credentialsSentToEmail": "登录信息已发送到您的邮箱",
|
||||||
|
"giftSentSuccess": "礼物已发送!",
|
||||||
|
"giftSentDesc": "收件人将通过邮件收到通知",
|
||||||
|
"giftPendingActivationDesc": "收件人已有活跃订阅。他们将收到激活礼物的链接。",
|
||||||
|
"autoLoginFailed": "自动登录失败",
|
||||||
|
"autoLoginProcessing": "正在登录...",
|
||||||
|
"periodLabels": {
|
||||||
|
"d1": "1天",
|
||||||
|
"d2": "2天",
|
||||||
|
"d3": "3天",
|
||||||
|
"d5": "5天",
|
||||||
|
"d7": "1周",
|
||||||
|
"d14": "2周",
|
||||||
|
"d30": "1个月",
|
||||||
|
"d60": "2个月",
|
||||||
|
"d90": "3个月",
|
||||||
|
"d180": "6个月",
|
||||||
|
"d365": "1年",
|
||||||
|
"d456": "1年3个月",
|
||||||
|
"nDays": "{{count}}天",
|
||||||
|
"nMonths": "{{count}}个月"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -84,6 +84,7 @@ const RESOURCE_TYPES = [
|
|||||||
'users',
|
'users',
|
||||||
'tickets',
|
'tickets',
|
||||||
'stats',
|
'stats',
|
||||||
|
'sales_stats',
|
||||||
'broadcasts',
|
'broadcasts',
|
||||||
'tariffs',
|
'tariffs',
|
||||||
'promocodes',
|
'promocodes',
|
||||||
@@ -106,6 +107,7 @@ const RESOURCE_TYPES = [
|
|||||||
'apps',
|
'apps',
|
||||||
'email_templates',
|
'email_templates',
|
||||||
'pinned_messages',
|
'pinned_messages',
|
||||||
|
'landings',
|
||||||
'updates',
|
'updates',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -116,6 +118,7 @@ const PAGE_SIZE_OPTIONS = [20, 50, 100] as const;
|
|||||||
const AUTO_REFRESH_INTERVAL = 30_000;
|
const AUTO_REFRESH_INTERVAL = 30_000;
|
||||||
|
|
||||||
interface FiltersState {
|
interface FiltersState {
|
||||||
|
userId: string;
|
||||||
action: string;
|
action: string;
|
||||||
resource: string;
|
resource: string;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -124,6 +127,7 @@ interface FiltersState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const INITIAL_FILTERS: FiltersState = {
|
const INITIAL_FILTERS: FiltersState = {
|
||||||
|
userId: '',
|
||||||
action: '',
|
action: '',
|
||||||
resource: '',
|
resource: '',
|
||||||
status: '',
|
status: '',
|
||||||
@@ -431,9 +435,6 @@ export default function AdminAuditLog() {
|
|||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [exportError, setExportError] = useState<string | null>(null);
|
const [exportError, setExportError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Auto-refresh interval ref
|
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
|
|
||||||
// Build query params
|
// Build query params
|
||||||
const queryParams = useMemo((): AuditLogFilters => {
|
const queryParams = useMemo((): AuditLogFilters => {
|
||||||
const params: AuditLogFilters = {
|
const params: AuditLogFilters = {
|
||||||
@@ -441,6 +442,10 @@ export default function AdminAuditLog() {
|
|||||||
offset: page * pageSize,
|
offset: page * pageSize,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parsedUserId = parseInt(appliedFilters.userId, 10);
|
||||||
|
if (!isNaN(parsedUserId) && parsedUserId > 0) {
|
||||||
|
params.user_id = parsedUserId;
|
||||||
|
}
|
||||||
if (appliedFilters.action.trim()) {
|
if (appliedFilters.action.trim()) {
|
||||||
params.action = appliedFilters.action.trim();
|
params.action = appliedFilters.action.trim();
|
||||||
}
|
}
|
||||||
@@ -467,21 +472,12 @@ export default function AdminAuditLog() {
|
|||||||
refetchInterval: autoRefresh ? AUTO_REFRESH_INTERVAL : false,
|
refetchInterval: autoRefresh ? AUTO_REFRESH_INTERVAL : false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-refresh visual indicator
|
// RBAC users for filter chips
|
||||||
useEffect(() => {
|
const { data: rbacUsers } = useQuery({
|
||||||
if (autoRefresh) {
|
queryKey: ['rbac-users'],
|
||||||
intervalRef.current = setInterval(() => {
|
queryFn: () => rbacApi.getRbacUsers(),
|
||||||
// The visual indicator updates are driven by isFetching from react-query
|
staleTime: 5 * 60 * 1000,
|
||||||
}, AUTO_REFRESH_INTERVAL);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [autoRefresh]);
|
|
||||||
|
|
||||||
const entries = data?.items ?? [];
|
const entries = data?.items ?? [];
|
||||||
const total = data?.total ?? 0;
|
const total = data?.total ?? 0;
|
||||||
@@ -517,8 +513,11 @@ export default function AdminAuditLog() {
|
|||||||
setExporting(true);
|
setExporting(true);
|
||||||
try {
|
try {
|
||||||
const exportParams: AuditLogFilters = {};
|
const exportParams: AuditLogFilters = {};
|
||||||
|
const exportUserId = parseInt(appliedFilters.userId, 10);
|
||||||
|
if (!isNaN(exportUserId) && exportUserId > 0) exportParams.user_id = exportUserId;
|
||||||
if (appliedFilters.action.trim()) exportParams.action = appliedFilters.action.trim();
|
if (appliedFilters.action.trim()) exportParams.action = appliedFilters.action.trim();
|
||||||
if (appliedFilters.resource) exportParams.resource_type = appliedFilters.resource;
|
if (appliedFilters.resource) exportParams.resource_type = appliedFilters.resource;
|
||||||
|
if (appliedFilters.status) exportParams.status = appliedFilters.status;
|
||||||
if (appliedFilters.dateFrom) exportParams.date_from = appliedFilters.dateFrom;
|
if (appliedFilters.dateFrom) exportParams.date_from = appliedFilters.dateFrom;
|
||||||
if (appliedFilters.dateTo) exportParams.date_to = appliedFilters.dateTo;
|
if (appliedFilters.dateTo) exportParams.date_to = appliedFilters.dateTo;
|
||||||
|
|
||||||
@@ -548,6 +547,7 @@ export default function AdminAuditLog() {
|
|||||||
|
|
||||||
const hasActiveFilters = useMemo(() => {
|
const hasActiveFilters = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
|
appliedFilters.userId.trim() !== '' ||
|
||||||
appliedFilters.action.trim() !== '' ||
|
appliedFilters.action.trim() !== '' ||
|
||||||
appliedFilters.resource !== '' ||
|
appliedFilters.resource !== '' ||
|
||||||
appliedFilters.status !== '' ||
|
appliedFilters.status !== '' ||
|
||||||
@@ -649,6 +649,51 @@ export default function AdminAuditLog() {
|
|||||||
{filtersOpen && (
|
{filtersOpen && (
|
||||||
<div className="border-t border-dark-700 p-4">
|
<div className="border-t border-dark-700 p-4">
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{/* User filter */}
|
||||||
|
{rbacUsers && rbacUsers.length > 0 && (
|
||||||
|
<div className="sm:col-span-2 lg:col-span-3">
|
||||||
|
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.auditLog.filters.user')}
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{rbacUsers.map((ru) => {
|
||||||
|
const isSelected = filters.userId === String(ru.user_id);
|
||||||
|
const displayName =
|
||||||
|
ru.first_name || ru.email || ru.username || `#${ru.user_id}`;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={ru.user_id}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
onClick={() =>
|
||||||
|
setFilters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
userId: isSelected ? '' : String(ru.user_id),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className={`flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-500 focus-visible:ring-offset-1 focus-visible:ring-offset-dark-900 ${
|
||||||
|
isSelected
|
||||||
|
? 'border-accent-500 bg-accent-500/20 text-accent-300'
|
||||||
|
: 'border-dark-600 bg-dark-900 text-dark-300 hover:border-dark-500 hover:text-dark-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`flex h-4 w-4 items-center justify-center rounded border text-xs ${
|
||||||
|
isSelected
|
||||||
|
? 'border-accent-500 bg-accent-500 text-white'
|
||||||
|
: 'border-dark-500 bg-dark-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSelected && '✓'}
|
||||||
|
</span>
|
||||||
|
<span>{displayName}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Action search */}
|
{/* Action search */}
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
import { campaignsApi, CampaignListItem, CampaignBonusType } from '../api/campaigns';
|
import { campaignsApi, CampaignListItem, CampaignBonusType } from '../api/campaigns';
|
||||||
import { PlusIcon, EditIcon, TrashIcon, CheckIcon, XIcon, ChartIcon } from '../components/icons';
|
import { PlusIcon, EditIcon, TrashIcon, CheckIcon, XIcon, ChartIcon } from '../components/icons';
|
||||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
// Bonus type labels and colors
|
// Bonus type labels and colors
|
||||||
const bonusTypeConfig: Record<
|
const bonusTypeConfig: Record<
|
||||||
CampaignBonusType,
|
CampaignBonusType,
|
||||||
@@ -69,9 +71,20 @@ export default function AdminCampaigns() {
|
|||||||
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const { data: campaignsData, isLoading } = useQuery({
|
const {
|
||||||
|
data: campaignsData,
|
||||||
|
isLoading,
|
||||||
|
hasNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
} = useInfiniteQuery({
|
||||||
queryKey: ['admin-campaigns'],
|
queryKey: ['admin-campaigns'],
|
||||||
queryFn: () => campaignsApi.getCampaigns(true),
|
queryFn: ({ pageParam = 0 }) => campaignsApi.getCampaigns(true, pageParam, PAGE_SIZE),
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
|
const loaded = allPages.reduce((sum, p) => sum + p.campaigns.length, 0);
|
||||||
|
return loaded < lastPage.total ? loaded : undefined;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: overview } = useQuery({
|
const { data: overview } = useQuery({
|
||||||
@@ -96,7 +109,7 @@ export default function AdminCampaigns() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const campaigns = campaignsData?.campaigns || [];
|
const campaigns = campaignsData?.pages.flatMap((p) => p.campaigns) ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
@@ -261,6 +274,21 @@ export default function AdminCampaigns() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Load more */}
|
||||||
|
{hasNextPage && (
|
||||||
|
<button
|
||||||
|
onClick={() => fetchNextPage()}
|
||||||
|
disabled={isFetchingNextPage}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-xl border border-dark-700 bg-dark-800 py-3 text-sm font-medium text-dark-300 transition-colors hover:border-dark-600 hover:text-dark-100 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isFetchingNextPage ? (
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-dark-500 border-t-accent-500" />
|
||||||
|
) : (
|
||||||
|
t('admin.campaigns.loadMore', 'Load more')
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
1094
src/pages/AdminLandingEditor.tsx
Normal file
1094
src/pages/AdminLandingEditor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
487
src/pages/AdminLandings.tsx
Normal file
487
src/pages/AdminLandings.tsx
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { adminLandingsApi, LandingListItem, resolveLocaleDisplay } from '../api/landings';
|
||||||
|
import { useNotify } from '@/platform';
|
||||||
|
import { copyToClipboard } from '../utils/clipboard';
|
||||||
|
import { getApiErrorMessage } from '../utils/api-error';
|
||||||
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
import { BackIcon, PlusIcon, TrashIcon, GripIcon } from '../components/icons/LandingIcons';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
|
||||||
|
// Icons (non-shared, page-specific)
|
||||||
|
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.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>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 XIcon = () => (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
|
||||||
|
const GiftIcon = () => (
|
||||||
|
<svg className="h-4 w-4" 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>
|
||||||
|
);
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
|
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.75H9.75a.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>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============ Sortable Landing Card ============
|
||||||
|
|
||||||
|
interface SortableLandingCardProps {
|
||||||
|
landing: LandingListItem;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onToggle: () => void;
|
||||||
|
onCopyUrl: () => void;
|
||||||
|
isPendingDelete?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableLandingCard({
|
||||||
|
landing,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onToggle,
|
||||||
|
onCopyUrl,
|
||||||
|
isPendingDelete,
|
||||||
|
}: SortableLandingCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: landing.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
zIndex: isDragging ? 50 : undefined,
|
||||||
|
position: isDragging ? 'relative' : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl border bg-dark-800 p-3 transition-colors sm:p-4',
|
||||||
|
isDragging
|
||||||
|
? 'border-accent-500/50 shadow-xl shadow-accent-500/20'
|
||||||
|
: landing.is_active
|
||||||
|
? 'border-dark-700'
|
||||||
|
: 'border-dark-700/50 opacity-60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2 sm:gap-3">
|
||||||
|
{/* Drag handle */}
|
||||||
|
<button
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="mt-0.5 flex-shrink-0 cursor-grab touch-none rounded-lg p-2 text-dark-500 hover:bg-dark-700/50 hover:text-dark-300 active:cursor-grabbing sm:mt-1 sm:p-1.5"
|
||||||
|
title={t('admin.tariffs.dragToReorder')}
|
||||||
|
>
|
||||||
|
<GripIcon />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Content + Actions wrapper */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{/* Top row: title/slug + actions (desktop) */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="mb-1 flex flex-wrap items-center gap-1.5 sm:gap-2">
|
||||||
|
<h3 className="truncate font-medium text-dark-100">
|
||||||
|
{resolveLocaleDisplay(landing.title)}
|
||||||
|
</h3>
|
||||||
|
<span className="shrink-0 rounded bg-dark-800 px-2 py-0.5 text-xs text-dark-400">
|
||||||
|
{landing.slug}
|
||||||
|
</span>
|
||||||
|
{landing.is_active ? (
|
||||||
|
<span className="shrink-0 rounded bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
|
||||||
|
{t('admin.landings.active')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="shrink-0 rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
|
||||||
|
{t('admin.landings.inactive')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{landing.gift_enabled && (
|
||||||
|
<span className="shrink-0 rounded bg-accent-500/20 px-1.5 py-0.5 text-xs text-accent-400">
|
||||||
|
<GiftIcon />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{landing.has_active_discount && (
|
||||||
|
<span className="rounded-full bg-accent-500/20 px-2 py-0.5 text-[10px] font-medium text-accent-400">
|
||||||
|
{t('admin.landings.discountActive', 'Discount')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-dark-400">
|
||||||
|
<span>
|
||||||
|
{landing.purchase_stats.total} {t('admin.landings.purchases')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions: hidden on mobile, shown on desktop */}
|
||||||
|
<div className="hidden shrink-0 items-center gap-1.5 sm:flex">
|
||||||
|
<button
|
||||||
|
onClick={onCopyUrl}
|
||||||
|
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
|
||||||
|
title={t('admin.landings.copyUrl')}
|
||||||
|
>
|
||||||
|
<CopyIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg p-2 transition-colors',
|
||||||
|
landing.is_active
|
||||||
|
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
|
||||||
|
: 'bg-dark-700 text-dark-400 hover:bg-dark-600',
|
||||||
|
)}
|
||||||
|
title={
|
||||||
|
landing.is_active ? t('admin.landings.inactive') : t('admin.landings.active')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{landing.is_active ? <CheckIcon /> : <XIcon />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onEdit}
|
||||||
|
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
|
||||||
|
title={t('admin.landings.edit')}
|
||||||
|
>
|
||||||
|
<EditIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg p-2 transition-colors',
|
||||||
|
isPendingDelete
|
||||||
|
? 'bg-error-500/20 text-error-400 ring-1 ring-error-500/30'
|
||||||
|
: 'bg-dark-700 text-dark-300 hover:bg-error-500/20 hover:text-error-400',
|
||||||
|
)}
|
||||||
|
title={
|
||||||
|
isPendingDelete
|
||||||
|
? t('admin.landings.deleteConfirm', {
|
||||||
|
title: resolveLocaleDisplay(landing.title),
|
||||||
|
})
|
||||||
|
: t('common.delete')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isPendingDelete ? (
|
||||||
|
<span className="px-1 text-xs font-medium">{t('common.delete')}?</span>
|
||||||
|
) : (
|
||||||
|
<TrashIcon />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions: shown on mobile only */}
|
||||||
|
<div className="mt-2 flex items-center gap-1.5 sm:hidden">
|
||||||
|
<button
|
||||||
|
onClick={onCopyUrl}
|
||||||
|
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
|
||||||
|
title={t('admin.landings.copyUrl')}
|
||||||
|
>
|
||||||
|
<CopyIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg p-2 transition-colors',
|
||||||
|
landing.is_active
|
||||||
|
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
|
||||||
|
: 'bg-dark-700 text-dark-400 hover:bg-dark-600',
|
||||||
|
)}
|
||||||
|
title={landing.is_active ? t('admin.landings.inactive') : t('admin.landings.active')}
|
||||||
|
>
|
||||||
|
{landing.is_active ? <CheckIcon /> : <XIcon />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onEdit}
|
||||||
|
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
|
||||||
|
title={t('admin.landings.edit')}
|
||||||
|
>
|
||||||
|
<EditIcon />
|
||||||
|
</button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg p-2 transition-colors',
|
||||||
|
isPendingDelete
|
||||||
|
? 'bg-error-500/20 text-error-400 ring-1 ring-error-500/30'
|
||||||
|
: 'bg-dark-700 text-dark-300 hover:bg-error-500/20 hover:text-error-400',
|
||||||
|
)}
|
||||||
|
title={
|
||||||
|
isPendingDelete
|
||||||
|
? t('admin.landings.deleteConfirm', {
|
||||||
|
title: resolveLocaleDisplay(landing.title),
|
||||||
|
})
|
||||||
|
: t('common.delete')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isPendingDelete ? (
|
||||||
|
<span className="px-1 text-xs font-medium">{t('common.delete')}?</span>
|
||||||
|
) : (
|
||||||
|
<TrashIcon />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Main Page ============
|
||||||
|
|
||||||
|
export default function AdminLandings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const notify = useNotify();
|
||||||
|
const { capabilities } = usePlatform();
|
||||||
|
|
||||||
|
const [localLandings, setLocalLandings] = useState<LandingListItem[]>([]);
|
||||||
|
const [orderChanged, setOrderChanged] = useState(false);
|
||||||
|
const [pendingDeleteId, setPendingDeleteId] = useState<number | null>(null);
|
||||||
|
const deleteTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (deleteTimeoutRef.current) clearTimeout(deleteTimeoutRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Queries
|
||||||
|
const { data: landingsData, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin-landings'],
|
||||||
|
queryFn: () => adminLandingsApi.list(),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync fetched data to local state
|
||||||
|
useEffect(() => {
|
||||||
|
if (landingsData && !orderChanged) {
|
||||||
|
setLocalLandings(landingsData);
|
||||||
|
}
|
||||||
|
}, [landingsData, orderChanged]);
|
||||||
|
|
||||||
|
// Save order mutation
|
||||||
|
const saveOrderMutation = useMutation({
|
||||||
|
mutationFn: (landingIds: number[]) => adminLandingsApi.reorder(landingIds),
|
||||||
|
onSuccess: () => {
|
||||||
|
setOrderChanged(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-landings'] });
|
||||||
|
notify.success(t('admin.landings.orderSaved'));
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
notify.error(getApiErrorMessage(err, t('common.error')));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: adminLandingsApi.delete,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-landings'] });
|
||||||
|
notify.success(t('admin.landings.deleted'));
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
notify.error(getApiErrorMessage(err, t('common.error')));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDelete = (landing: LandingListItem) => {
|
||||||
|
if (pendingDeleteId === landing.id) {
|
||||||
|
deleteMutation.mutate(landing.id);
|
||||||
|
setPendingDeleteId(null);
|
||||||
|
} else {
|
||||||
|
setPendingDeleteId(landing.id);
|
||||||
|
if (deleteTimeoutRef.current) clearTimeout(deleteTimeoutRef.current);
|
||||||
|
deleteTimeoutRef.current = setTimeout(
|
||||||
|
() => setPendingDeleteId((prev) => (prev === landing.id ? null : prev)),
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMutation = useMutation({
|
||||||
|
mutationFn: adminLandingsApi.toggle,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-landings'] });
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
notify.error(getApiErrorMessage(err, t('common.error')));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCopyUrl = async (slug: string) => {
|
||||||
|
const url = `${window.location.origin}/buy/${slug}`;
|
||||||
|
try {
|
||||||
|
await copyToClipboard(url);
|
||||||
|
notify.success(t('admin.landings.urlCopied'));
|
||||||
|
} catch {
|
||||||
|
// Clipboard write failed silently
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// DnD sensors
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (over && active.id !== over.id) {
|
||||||
|
setLocalLandings((prev) => {
|
||||||
|
const oldIndex = prev.findIndex((l) => l.id === active.id);
|
||||||
|
const newIndex = prev.findIndex((l) => l.id === over.id);
|
||||||
|
if (oldIndex === -1 || newIndex === -1) return prev;
|
||||||
|
return arrayMove(prev, oldIndex, newIndex);
|
||||||
|
});
|
||||||
|
setOrderChanged(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSaveOrder = () => {
|
||||||
|
saveOrderMutation.mutate(localLandings.map((l) => l.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
{/* Show back button only on web, not in Telegram Mini App */}
|
||||||
|
{!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"
|
||||||
|
>
|
||||||
|
<BackIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-dark-100">{t('admin.landings.title')}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{orderChanged && (
|
||||||
|
<button
|
||||||
|
onClick={handleSaveOrder}
|
||||||
|
disabled={saveOrderMutation.isPending}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-success-500 px-4 py-2 text-white transition-colors hover:bg-success-600"
|
||||||
|
>
|
||||||
|
{saveOrderMutation.isPending ? (
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
|
) : (
|
||||||
|
<SaveIcon />
|
||||||
|
)}
|
||||||
|
{t('admin.landings.saveOrder')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/admin/landings/create')}
|
||||||
|
className="flex items-center justify-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
{t('admin.landings.create')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Drag hint */}
|
||||||
|
<div className="mb-4 flex items-center gap-2 text-sm text-dark-500">
|
||||||
|
<GripIcon />
|
||||||
|
{t('admin.tariffs.dragToReorder')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Landings List */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : localLandings.length === 0 ? (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<p className="text-dark-400">{t('common.noData')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||||
|
<SortableContext
|
||||||
|
items={localLandings.map((l) => l.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{localLandings.map((landing) => (
|
||||||
|
<SortableLandingCard
|
||||||
|
key={landing.id}
|
||||||
|
landing={landing}
|
||||||
|
onEdit={() => navigate(`/admin/landings/${landing.id}/edit`)}
|
||||||
|
onDelete={() => handleDelete(landing)}
|
||||||
|
onToggle={() => toggleMutation.mutate(landing.id)}
|
||||||
|
onCopyUrl={() => handleCopyUrl(landing.slug)}
|
||||||
|
isPendingDelete={pendingDeleteId === landing.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -304,6 +304,16 @@ const ClipboardDocumentListIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const RectangleGroupIcon = () => (
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 01-1.125-1.125v-3.75zM14.25 8.625c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-8.25zM3.75 16.125c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v2.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-2.25z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
interface AdminItem {
|
interface AdminItem {
|
||||||
to: string;
|
to: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
@@ -499,6 +509,13 @@ export default function AdminPanel() {
|
|||||||
description: t('admin.panel.paymentMethodsDesc'),
|
description: t('admin.panel.paymentMethodsDesc'),
|
||||||
permission: 'payment_methods:read',
|
permission: 'payment_methods:read',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: '/admin/landings',
|
||||||
|
icon: <RectangleGroupIcon />,
|
||||||
|
title: t('admin.nav.landings'),
|
||||||
|
description: t('admin.panel.landingsDesc'),
|
||||||
|
permission: 'landings:read',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
TariffUpdateRequest,
|
TariffUpdateRequest,
|
||||||
PeriodPrice,
|
PeriodPrice,
|
||||||
ServerInfo,
|
ServerInfo,
|
||||||
|
ExternalSquadInfo,
|
||||||
} from '../api/tariffs';
|
} from '../api/tariffs';
|
||||||
import { AdminBackButton } from '../components/admin';
|
import { AdminBackButton } from '../components/admin';
|
||||||
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
|
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
|
||||||
@@ -105,6 +106,7 @@ export default function AdminTariffCreate() {
|
|||||||
const [tierLevel, setTierLevel] = useState<number | ''>(1);
|
const [tierLevel, setTierLevel] = useState<number | ''>(1);
|
||||||
const [periodPrices, setPeriodPrices] = useState<PeriodPrice[]>([]);
|
const [periodPrices, setPeriodPrices] = useState<PeriodPrice[]>([]);
|
||||||
const [selectedSquads, setSelectedSquads] = useState<string[]>([]);
|
const [selectedSquads, setSelectedSquads] = useState<string[]>([]);
|
||||||
|
const [selectedExternalSquad, setSelectedExternalSquad] = useState<string | null>(null);
|
||||||
const [selectedPromoGroups, setSelectedPromoGroups] = useState<number[]>([]);
|
const [selectedPromoGroups, setSelectedPromoGroups] = useState<number[]>([]);
|
||||||
const [dailyPriceKopeks, setDailyPriceKopeks] = useState<number | ''>(0);
|
const [dailyPriceKopeks, setDailyPriceKopeks] = useState<number | ''>(0);
|
||||||
|
|
||||||
@@ -138,6 +140,12 @@ export default function AdminTariffCreate() {
|
|||||||
queryFn: () => tariffsApi.getAvailableServers(),
|
queryFn: () => tariffsApi.getAvailableServers(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch external squads
|
||||||
|
const { data: externalSquads = [] } = useQuery({
|
||||||
|
queryKey: ['admin-tariffs-external-squads'],
|
||||||
|
queryFn: () => tariffsApi.getAvailableExternalSquads(),
|
||||||
|
});
|
||||||
|
|
||||||
// Fetch promo groups
|
// Fetch promo groups
|
||||||
const { data: promoGroups = [] } = useQuery({
|
const { data: promoGroups = [] } = useQuery({
|
||||||
queryKey: ['admin-tariffs-promo-groups'],
|
queryKey: ['admin-tariffs-promo-groups'],
|
||||||
@@ -165,6 +173,7 @@ export default function AdminTariffCreate() {
|
|||||||
setTierLevel(data.tier_level || 1);
|
setTierLevel(data.tier_level || 1);
|
||||||
setPeriodPrices(data.period_prices?.length ? data.period_prices : []);
|
setPeriodPrices(data.period_prices?.length ? data.period_prices : []);
|
||||||
setSelectedSquads(data.allowed_squads || []);
|
setSelectedSquads(data.allowed_squads || []);
|
||||||
|
setSelectedExternalSquad(data.external_squad_uuid || null);
|
||||||
setSelectedPromoGroups(
|
setSelectedPromoGroups(
|
||||||
data.promo_groups?.filter((pg) => pg.is_selected).map((pg) => pg.id) || [],
|
data.promo_groups?.filter((pg) => pg.is_selected).map((pg) => pg.id) || [],
|
||||||
);
|
);
|
||||||
@@ -211,6 +220,7 @@ export default function AdminTariffCreate() {
|
|||||||
tier_level: toNumber(tierLevel, 1),
|
tier_level: toNumber(tierLevel, 1),
|
||||||
period_prices: isDaily ? [] : periodPrices.filter((p) => p.price_kopeks >= 0),
|
period_prices: isDaily ? [] : periodPrices.filter((p) => p.price_kopeks >= 0),
|
||||||
allowed_squads: selectedSquads,
|
allowed_squads: selectedSquads,
|
||||||
|
external_squad_uuid: selectedExternalSquad || null,
|
||||||
promo_group_ids: selectedPromoGroups.length > 0 ? selectedPromoGroups : undefined,
|
promo_group_ids: selectedPromoGroups.length > 0 ? selectedPromoGroups : undefined,
|
||||||
traffic_topup_enabled: trafficTopupEnabled,
|
traffic_topup_enabled: trafficTopupEnabled,
|
||||||
traffic_topup_packages: trafficTopupPackages,
|
traffic_topup_packages: trafficTopupPackages,
|
||||||
@@ -660,6 +670,77 @@ export default function AdminTariffCreate() {
|
|||||||
|
|
||||||
{activeTab === 'servers' && (
|
{activeTab === 'servers' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* External Squad */}
|
||||||
|
{externalSquads.length > 0 && (
|
||||||
|
<div className="card space-y-4">
|
||||||
|
<h4 className="text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.tariffs.externalSquadTitle')}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-dark-400">{t('admin.tariffs.externalSquadHint')}</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedExternalSquad(null)}
|
||||||
|
className={`flex w-full items-center gap-3 rounded-lg p-3 text-left transition-colors ${
|
||||||
|
!selectedExternalSquad
|
||||||
|
? isDaily
|
||||||
|
? 'bg-warning-500/20 text-warning-300'
|
||||||
|
: 'bg-accent-500/20 text-accent-300'
|
||||||
|
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex h-5 w-5 items-center justify-center rounded-full ${
|
||||||
|
!selectedExternalSquad
|
||||||
|
? isDaily
|
||||||
|
? 'bg-warning-500 text-white'
|
||||||
|
: 'bg-accent-500 text-white'
|
||||||
|
: 'bg-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{!selectedExternalSquad && <CheckIcon />}
|
||||||
|
</div>
|
||||||
|
<span className="flex-1 text-sm font-medium">
|
||||||
|
{t('admin.tariffs.noExternalSquad')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{externalSquads.map((squad: ExternalSquadInfo) => {
|
||||||
|
const isSelected = selectedExternalSquad === squad.uuid;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={squad.uuid}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedExternalSquad(squad.uuid)}
|
||||||
|
className={`flex w-full items-center gap-3 rounded-lg p-3 text-left transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? isDaily
|
||||||
|
? 'bg-warning-500/20 text-warning-300'
|
||||||
|
: 'bg-accent-500/20 text-accent-300'
|
||||||
|
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex h-5 w-5 items-center justify-center rounded-full ${
|
||||||
|
isSelected
|
||||||
|
? isDaily
|
||||||
|
? 'bg-warning-500 text-white'
|
||||||
|
: 'bg-accent-500 text-white'
|
||||||
|
: 'bg-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSelected && <CheckIcon />}
|
||||||
|
</div>
|
||||||
|
<span className="flex-1 text-sm font-medium">{squad.name}</span>
|
||||||
|
<span className="text-xs text-dark-500">
|
||||||
|
{squad.members_count} {t('admin.tariffs.externalSquadUsers')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Servers */}
|
{/* Servers */}
|
||||||
<div className="card space-y-4">
|
<div className="card space-y-4">
|
||||||
<h4 className="text-sm font-medium text-dark-200">{t('admin.tariffs.serversTitle')}</h4>
|
<h4 className="text-sm font-medium text-dark-200">{t('admin.tariffs.serversTitle')}</h4>
|
||||||
|
|||||||
82
src/pages/AutoLogin.tsx
Normal file
82
src/pages/AutoLogin.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import { useSearchParams, useNavigate } from 'react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { authApi } from '../api/auth';
|
||||||
|
import { useAuthStore } from '../store/auth';
|
||||||
|
|
||||||
|
export default function AutoLogin() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { setTokens, setUser, checkAdminStatus } = useAuthStore();
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const attemptedRef = useRef(false);
|
||||||
|
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Prevent referrer leaking the token
|
||||||
|
const meta = document.createElement('meta');
|
||||||
|
meta.name = 'referrer';
|
||||||
|
meta.content = 'no-referrer';
|
||||||
|
document.head.appendChild(meta);
|
||||||
|
return () => {
|
||||||
|
document.head.removeChild(meta);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || attemptedRef.current) {
|
||||||
|
if (!token) setError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
attemptedRef.current = true;
|
||||||
|
|
||||||
|
authApi
|
||||||
|
.autoLogin(token)
|
||||||
|
.then(async (response) => {
|
||||||
|
setTokens(response.access_token, response.refresh_token);
|
||||||
|
setUser(response.user);
|
||||||
|
await checkAdminStatus();
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError(true);
|
||||||
|
});
|
||||||
|
}, [token, navigate, setTokens, setUser, checkAdminStatus]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh items-center justify-center bg-dark-950 px-4">
|
||||||
|
<div className="w-full max-w-sm rounded-2xl border border-dark-800/50 bg-dark-900/50 p-8 text-center">
|
||||||
|
{error ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-error-500/10">
|
||||||
|
<svg
|
||||||
|
className="h-8 w-8 text-error-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-dark-300">{t('landing.autoLoginFailed')}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/login', { replace: true })}
|
||||||
|
className="rounded-xl bg-accent-500 px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-400"
|
||||||
|
>
|
||||||
|
{t('auth.login', 'Login')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="mx-auto h-10 w-10 animate-spin rounded-full border-2 border-dark-600 border-t-accent-500" />
|
||||||
|
<p className="text-sm text-dark-300">{t('landing.autoLoginProcessing')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@ function TelegramLinkWidget() {
|
|||||||
const redirectUrl = `${window.location.origin}/auth/link/telegram/callback?csrf_state=${encodeURIComponent(csrfState)}`;
|
const redirectUrl = `${window.location.origin}/auth/link/telegram/callback?csrf_state=${encodeURIComponent(csrfState)}`;
|
||||||
|
|
||||||
const script = document.createElement('script');
|
const script = document.createElement('script');
|
||||||
script.src = 'https://telegram.org/js/telegram-widget.js?22';
|
script.src = 'https://telegram.org/js/telegram-widget.js?23';
|
||||||
script.setAttribute('data-telegram-login', botUsername);
|
script.setAttribute('data-telegram-login', botUsername);
|
||||||
script.setAttribute('data-size', 'small');
|
script.setAttribute('data-size', 'small');
|
||||||
script.setAttribute('data-radius', '8');
|
script.setAttribute('data-radius', '8');
|
||||||
|
|||||||
@@ -485,10 +485,7 @@ export default function Login() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<TelegramLoginButton
|
<TelegramLoginButton referralCode={referralCode || undefined} />
|
||||||
botUsername={botUsername}
|
|
||||||
referralCode={referralCode || undefined}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
792
src/pages/PurchaseSuccess.tsx
Normal file
792
src/pages/PurchaseSuccess.tsx
Normal file
@@ -0,0 +1,792 @@
|
|||||||
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate, useSearchParams } from 'react-router';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import { landingApi } from '../api/landings';
|
||||||
|
import { authApi } from '../api/auth';
|
||||||
|
import { useAuthStore } from '../store/auth';
|
||||||
|
import { copyToClipboard } from '../utils/clipboard';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
|
const MAX_POLL_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Sub-components
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function Spinner({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'animate-spin rounded-full border-2 border-dark-600 border-t-accent-500',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PendingState() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="flex flex-col items-center gap-6 text-center"
|
||||||
|
>
|
||||||
|
<Spinner className="h-16 w-16 border-[3px]" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-dark-50">
|
||||||
|
{t('landing.awaitingPayment', 'Awaiting payment')}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-dark-400">{t('landing.awaitingPaymentDesc')}</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CopyableField({ label, value }: { label: string; value: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCopy = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await copyToClipboard(value);
|
||||||
|
setCopied(true);
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
// Clipboard write failed silently
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 rounded-xl bg-dark-800/50 px-4 py-3">
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<p className="text-xs text-dark-400">{label}</p>
|
||||||
|
<p className="mt-0.5 font-mono text-sm text-dark-100">{value}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors',
|
||||||
|
copied
|
||||||
|
? 'bg-success-500/10 text-success-500'
|
||||||
|
: 'bg-dark-700/50 text-dark-300 hover:bg-dark-600/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{copied ? t('landing.copied', 'Copied!') : t('landing.copy', 'Copy')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CabinetCredentialsState({
|
||||||
|
cabinetEmail,
|
||||||
|
cabinetPassword,
|
||||||
|
autoLoginToken,
|
||||||
|
tariffName,
|
||||||
|
periodDays,
|
||||||
|
}: {
|
||||||
|
cabinetEmail: string;
|
||||||
|
cabinetPassword: string | null;
|
||||||
|
autoLoginToken: string | null;
|
||||||
|
tariffName: string | null;
|
||||||
|
periodDays: number | null;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { setTokens, setUser, checkAdminStatus } = useAuthStore();
|
||||||
|
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||||
|
const [loginError, setLoginError] = useState(false);
|
||||||
|
|
||||||
|
const handleGoToCabinet = useCallback(async () => {
|
||||||
|
if (!autoLoginToken) {
|
||||||
|
navigate('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoggingIn(true);
|
||||||
|
setLoginError(false);
|
||||||
|
try {
|
||||||
|
const response = await authApi.autoLogin(autoLoginToken);
|
||||||
|
setTokens(response.access_token, response.refresh_token);
|
||||||
|
setUser(response.user);
|
||||||
|
await checkAdminStatus();
|
||||||
|
navigate('/');
|
||||||
|
} catch {
|
||||||
|
setLoginError(true);
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
}
|
||||||
|
}, [autoLoginToken, navigate, setTokens, setUser, checkAdminStatus]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="flex flex-col items-center gap-6 text-center"
|
||||||
|
>
|
||||||
|
{/* Animated checkmark */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 200, damping: 15, delay: 0.1 }}
|
||||||
|
className="flex h-20 w-20 items-center justify-center rounded-full bg-success-500/10"
|
||||||
|
>
|
||||||
|
<motion.svg
|
||||||
|
initial={{ pathLength: 0, opacity: 0 }}
|
||||||
|
animate={{ pathLength: 1, opacity: 1 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.3 }}
|
||||||
|
className="h-10 w-10 text-success-500"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
>
|
||||||
|
<motion.path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
initial={{ pathLength: 0 }}
|
||||||
|
animate={{ pathLength: 1 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.3 }}
|
||||||
|
/>
|
||||||
|
</motion.svg>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-dark-50">{t('landing.cabinetReady')}</h1>
|
||||||
|
{tariffName && periodDays !== null && (
|
||||||
|
<p className="mt-1 text-sm text-dark-300">
|
||||||
|
{tariffName} — {periodDays} {t('landing.daysAccess')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Credentials */}
|
||||||
|
<div className="w-full space-y-3">
|
||||||
|
<CopyableField label={t('landing.cabinetEmail')} value={cabinetEmail} />
|
||||||
|
{cabinetPassword && (
|
||||||
|
<CopyableField label={t('landing.cabinetPassword')} value={cabinetPassword} />
|
||||||
|
)}
|
||||||
|
{cabinetPassword && <p className="text-xs text-dark-400">{t('landing.saveCredentials')}</p>}
|
||||||
|
{!cabinetPassword && (
|
||||||
|
<p className="text-xs text-dark-400">{t('landing.credentialsSentToEmail')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Go to Cabinet button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGoToCabinet}
|
||||||
|
disabled={isLoggingIn}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center justify-center gap-2 rounded-xl px-6 py-3 text-sm font-medium text-white transition-colors',
|
||||||
|
isLoggingIn ? 'cursor-not-allowed bg-accent-500/50' : 'bg-accent-500 hover:bg-accent-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLoggingIn ? (
|
||||||
|
<>
|
||||||
|
<Spinner className="h-4 w-4" />
|
||||||
|
{t('landing.autoLoginProcessing')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t('landing.goToCabinet')
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{loginError && <p className="text-xs text-error-400">{t('landing.autoLoginFailed')}</p>}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SuccessState({
|
||||||
|
subscriptionUrl,
|
||||||
|
cryptoLink,
|
||||||
|
contactValue,
|
||||||
|
recipientContactValue,
|
||||||
|
tariffName,
|
||||||
|
periodDays,
|
||||||
|
isGift,
|
||||||
|
giftMessage,
|
||||||
|
}: {
|
||||||
|
subscriptionUrl: string | null;
|
||||||
|
cryptoLink: string | null;
|
||||||
|
contactValue: string | null;
|
||||||
|
recipientContactValue: string | null;
|
||||||
|
tariffName: string | null;
|
||||||
|
periodDays: number | null;
|
||||||
|
isGift: boolean;
|
||||||
|
giftMessage: string | null;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCopy = useCallback(async () => {
|
||||||
|
const url = subscriptionUrl ?? cryptoLink;
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await copyToClipboard(url);
|
||||||
|
setCopied(true);
|
||||||
|
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current);
|
||||||
|
copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
// Clipboard write failed silently
|
||||||
|
}
|
||||||
|
}, [subscriptionUrl, cryptoLink]);
|
||||||
|
|
||||||
|
const displayUrl = subscriptionUrl ?? cryptoLink;
|
||||||
|
const displayContact = isGift ? recipientContactValue : contactValue;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="flex flex-col items-center gap-6 text-center"
|
||||||
|
>
|
||||||
|
{/* Animated checkmark */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 200, damping: 15, delay: 0.1 }}
|
||||||
|
className="flex h-20 w-20 items-center justify-center rounded-full bg-success-500/10"
|
||||||
|
>
|
||||||
|
<motion.svg
|
||||||
|
initial={{ pathLength: 0, opacity: 0 }}
|
||||||
|
animate={{ pathLength: 1, opacity: 1 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.3 }}
|
||||||
|
className="h-10 w-10 text-success-500"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
>
|
||||||
|
<motion.path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
initial={{ pathLength: 0 }}
|
||||||
|
animate={{ pathLength: 1 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.3 }}
|
||||||
|
/>
|
||||||
|
</motion.svg>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-dark-50">
|
||||||
|
{isGift ? t('landing.giftSentSuccess') : t('landing.purchaseSuccess')}
|
||||||
|
</h1>
|
||||||
|
{tariffName && periodDays !== null && (
|
||||||
|
<p className="mt-1 text-sm text-dark-300">
|
||||||
|
{tariffName} — {periodDays} {t('landing.daysAccess')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{displayContact && (
|
||||||
|
<p className="mt-2 text-sm text-dark-400">
|
||||||
|
{isGift
|
||||||
|
? t('landing.giftSentTo', { contact: displayContact })
|
||||||
|
: t('landing.keySentTo', { contact: displayContact })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{isGift && giftMessage && (
|
||||||
|
<p className="mt-2 text-sm italic text-dark-400">
|
||||||
|
{t('landing.giftMessage')}: {giftMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* QR Code */}
|
||||||
|
{displayUrl && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-2xl bg-white p-5">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={displayUrl}
|
||||||
|
size={200}
|
||||||
|
level="M"
|
||||||
|
includeMargin={false}
|
||||||
|
className="h-[200px] w-[200px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Copy button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-medium transition-all duration-200',
|
||||||
|
copied
|
||||||
|
? 'bg-success-500/10 text-success-500'
|
||||||
|
: 'bg-dark-800/50 text-dark-200 hover:bg-dark-700/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
{t('landing.copied', 'Copied!')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<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.75H9.75a.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>
|
||||||
|
{t('landing.copyLink', 'Copy link')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PendingActivationState({
|
||||||
|
tariffName,
|
||||||
|
periodDays,
|
||||||
|
giftMessage,
|
||||||
|
isGift,
|
||||||
|
isActivating,
|
||||||
|
onActivate,
|
||||||
|
autoLoginToken,
|
||||||
|
}: {
|
||||||
|
tariffName: string | null;
|
||||||
|
periodDays: number | null;
|
||||||
|
giftMessage: string | null;
|
||||||
|
isGift: boolean;
|
||||||
|
isActivating: boolean;
|
||||||
|
onActivate: () => void;
|
||||||
|
autoLoginToken: string | null;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { setTokens, setUser, checkAdminStatus } = useAuthStore();
|
||||||
|
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||||
|
|
||||||
|
const handleGoToCabinet = useCallback(async () => {
|
||||||
|
if (!autoLoginToken) {
|
||||||
|
navigate('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoggingIn(true);
|
||||||
|
try {
|
||||||
|
const response = await authApi.autoLogin(autoLoginToken);
|
||||||
|
setTokens(response.access_token, response.refresh_token);
|
||||||
|
setUser(response.user);
|
||||||
|
await checkAdminStatus();
|
||||||
|
navigate('/');
|
||||||
|
} catch {
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
navigate('/login');
|
||||||
|
}
|
||||||
|
}, [autoLoginToken, navigate, setTokens, setUser, checkAdminStatus]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="flex flex-col items-center gap-6 text-center"
|
||||||
|
>
|
||||||
|
{/* Warning icon */}
|
||||||
|
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-warning-500/10">
|
||||||
|
<svg
|
||||||
|
className="h-10 w-10 text-warning-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-dark-50">{t('landing.pendingActivation')}</h1>
|
||||||
|
{tariffName && periodDays !== null && (
|
||||||
|
<p className="mt-1 text-sm text-dark-300">
|
||||||
|
{tariffName} — {periodDays} {t('landing.daysAccess')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 text-sm text-dark-400">{t('landing.pendingActivationDesc')}</p>
|
||||||
|
{isGift && giftMessage && (
|
||||||
|
<p className="mt-2 text-sm italic text-dark-400">
|
||||||
|
{t('landing.giftMessage')}: {giftMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full flex-col gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onActivate}
|
||||||
|
disabled={isActivating}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center gap-2 rounded-xl px-6 py-3 text-sm font-medium text-white transition-colors',
|
||||||
|
isActivating
|
||||||
|
? 'cursor-not-allowed bg-accent-500/50'
|
||||||
|
: 'bg-accent-500 hover:bg-accent-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isActivating ? (
|
||||||
|
<>
|
||||||
|
<Spinner className="h-4 w-4" />
|
||||||
|
{t('landing.activating')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t('landing.activateNow')
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{autoLoginToken && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGoToCabinet}
|
||||||
|
disabled={isLoggingIn}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center gap-2 rounded-xl px-6 py-3 text-sm font-medium transition-colors',
|
||||||
|
isLoggingIn
|
||||||
|
? 'cursor-not-allowed bg-dark-800/30 text-dark-400'
|
||||||
|
: 'bg-dark-800/50 text-dark-200 hover:bg-dark-700/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLoggingIn ? (
|
||||||
|
<>
|
||||||
|
<Spinner className="h-4 w-4" />
|
||||||
|
{t('landing.autoLoginProcessing')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t('landing.goToCabinet')
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GiftPendingActivationState({
|
||||||
|
tariffName,
|
||||||
|
periodDays,
|
||||||
|
recipientContactValue,
|
||||||
|
giftMessage,
|
||||||
|
}: {
|
||||||
|
tariffName: string | null;
|
||||||
|
periodDays: number | null;
|
||||||
|
recipientContactValue: string | null;
|
||||||
|
giftMessage: string | null;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="flex flex-col items-center gap-6 text-center"
|
||||||
|
>
|
||||||
|
{/* Animated checkmark */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 200, damping: 15, delay: 0.1 }}
|
||||||
|
className="flex h-20 w-20 items-center justify-center rounded-full bg-success-500/10"
|
||||||
|
>
|
||||||
|
<motion.svg
|
||||||
|
initial={{ pathLength: 0, opacity: 0 }}
|
||||||
|
animate={{ pathLength: 1, opacity: 1 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.3 }}
|
||||||
|
className="h-10 w-10 text-success-500"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
>
|
||||||
|
<motion.path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
initial={{ pathLength: 0 }}
|
||||||
|
animate={{ pathLength: 1 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.3 }}
|
||||||
|
/>
|
||||||
|
</motion.svg>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-dark-50">{t('landing.giftSentSuccess')}</h1>
|
||||||
|
{tariffName && periodDays !== null && (
|
||||||
|
<p className="mt-1 text-sm text-dark-300">
|
||||||
|
{tariffName} — {periodDays} {t('landing.daysAccess')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{recipientContactValue && (
|
||||||
|
<p className="mt-2 text-sm text-dark-400">
|
||||||
|
{t('landing.giftSentTo', { contact: recipientContactValue })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 text-sm text-dark-400">{t('landing.giftPendingActivationDesc')}</p>
|
||||||
|
{giftMessage && (
|
||||||
|
<p className="mt-2 text-sm italic text-dark-400">
|
||||||
|
{t('landing.giftMessage')}: {giftMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FailedState() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="flex flex-col items-center gap-6 text-center"
|
||||||
|
>
|
||||||
|
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-error-500/10">
|
||||||
|
<svg
|
||||||
|
className="h-10 w-10 text-error-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-dark-50">{t('landing.purchaseFailed')}</h1>
|
||||||
|
<p className="mt-2 text-sm text-dark-400">{t('landing.purchaseFailedDesc')}</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PollTimedOutState({ onRetry }: { onRetry: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="flex flex-col items-center gap-6 text-center"
|
||||||
|
>
|
||||||
|
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-dark-800/50">
|
||||||
|
<svg
|
||||||
|
className="h-10 w-10 text-dark-400"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-dark-50">
|
||||||
|
{t('landing.pollTimedOut', 'Taking longer than expected')}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-dark-400">
|
||||||
|
{t(
|
||||||
|
'landing.pollTimedOutDesc',
|
||||||
|
'Payment processing is taking longer than usual. You can try checking again.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRetry}
|
||||||
|
className="rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
|
||||||
|
>
|
||||||
|
{t('common.retry', 'Retry')}
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Main Component
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export default function PurchaseSuccess() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { token } = useParams<{ token: string }>();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const isActivateHint = searchParams.get('activate') === '1';
|
||||||
|
const pollStart = useRef(Date.now());
|
||||||
|
const [pollTimedOut, setPollTimedOut] = useState(false);
|
||||||
|
const [isActivating, setIsActivating] = useState(false);
|
||||||
|
const [activationError, setActivationError] = useState(false);
|
||||||
|
const activatingRef = useRef(false);
|
||||||
|
|
||||||
|
// Referrer-Policy: prevent leaking payment token via referer header
|
||||||
|
useEffect(() => {
|
||||||
|
const meta = document.createElement('meta');
|
||||||
|
meta.name = 'referrer';
|
||||||
|
meta.content = 'no-referrer';
|
||||||
|
document.head.appendChild(meta);
|
||||||
|
return () => {
|
||||||
|
document.head.removeChild(meta);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: purchaseStatus,
|
||||||
|
isError,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['purchase-status', token],
|
||||||
|
queryFn: () => landingApi.getPurchaseStatus(token!),
|
||||||
|
enabled: !!token && !pollTimedOut,
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const currentStatus = query.state.data?.status;
|
||||||
|
if (currentStatus === 'pending' || currentStatus === 'paid') {
|
||||||
|
if (Date.now() - pollStart.current > MAX_POLL_MS) {
|
||||||
|
setPollTimedOut(true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return 3_000;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
retry: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRetryPoll = useCallback(() => {
|
||||||
|
pollStart.current = Date.now();
|
||||||
|
setPollTimedOut(false);
|
||||||
|
refetch();
|
||||||
|
}, [refetch]);
|
||||||
|
|
||||||
|
const handleActivate = useCallback(async () => {
|
||||||
|
if (!token || activatingRef.current) return;
|
||||||
|
activatingRef.current = true;
|
||||||
|
setIsActivating(true);
|
||||||
|
setActivationError(false);
|
||||||
|
try {
|
||||||
|
const result = await landingApi.activatePurchase(token);
|
||||||
|
queryClient.setQueryData(['purchase-status', token], result);
|
||||||
|
} catch {
|
||||||
|
setActivationError(true);
|
||||||
|
} finally {
|
||||||
|
activatingRef.current = false;
|
||||||
|
setIsActivating(false);
|
||||||
|
}
|
||||||
|
}, [token, queryClient]);
|
||||||
|
|
||||||
|
const isSuccess = purchaseStatus?.status === 'delivered';
|
||||||
|
const isPendingActivation = purchaseStatus?.status === 'pending_activation';
|
||||||
|
const isFailed = purchaseStatus?.status === 'failed' || purchaseStatus?.status === 'expired';
|
||||||
|
|
||||||
|
// Gift pending activation → buyer sees "gift sent" message, not the activate button.
|
||||||
|
// Recipient arrives via email link with ?activate=1 and sees the activate button instead.
|
||||||
|
const isGiftPendingActivation = isPendingActivation && purchaseStatus?.is_gift && !isActivateHint;
|
||||||
|
|
||||||
|
// Email self-purchase delivered → show cabinet credentials
|
||||||
|
const isEmailSelfPurchase =
|
||||||
|
isSuccess &&
|
||||||
|
purchaseStatus.contact_type === 'email' &&
|
||||||
|
!purchaseStatus.is_gift &&
|
||||||
|
purchaseStatus.cabinet_email;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh items-center justify-center bg-dark-950 px-4">
|
||||||
|
<div className="w-full max-w-md rounded-2xl border border-dark-800/50 bg-dark-900/50 p-8">
|
||||||
|
{isError ? (
|
||||||
|
<FailedState />
|
||||||
|
) : isEmailSelfPurchase ? (
|
||||||
|
<CabinetCredentialsState
|
||||||
|
cabinetEmail={purchaseStatus.cabinet_email!}
|
||||||
|
cabinetPassword={purchaseStatus.cabinet_password}
|
||||||
|
autoLoginToken={purchaseStatus.auto_login_token}
|
||||||
|
tariffName={purchaseStatus.tariff_name}
|
||||||
|
periodDays={purchaseStatus.period_days}
|
||||||
|
/>
|
||||||
|
) : isSuccess ? (
|
||||||
|
<SuccessState
|
||||||
|
subscriptionUrl={purchaseStatus.subscription_url}
|
||||||
|
cryptoLink={purchaseStatus.subscription_crypto_link}
|
||||||
|
contactValue={purchaseStatus.contact_value}
|
||||||
|
recipientContactValue={purchaseStatus.recipient_contact_value}
|
||||||
|
tariffName={purchaseStatus.tariff_name}
|
||||||
|
periodDays={purchaseStatus.period_days}
|
||||||
|
isGift={purchaseStatus.is_gift}
|
||||||
|
giftMessage={purchaseStatus.gift_message}
|
||||||
|
/>
|
||||||
|
) : isGiftPendingActivation ? (
|
||||||
|
<GiftPendingActivationState
|
||||||
|
tariffName={purchaseStatus.tariff_name}
|
||||||
|
periodDays={purchaseStatus.period_days}
|
||||||
|
recipientContactValue={purchaseStatus.recipient_contact_value}
|
||||||
|
giftMessage={purchaseStatus.gift_message}
|
||||||
|
/>
|
||||||
|
) : isPendingActivation ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PendingActivationState
|
||||||
|
tariffName={purchaseStatus.tariff_name}
|
||||||
|
periodDays={purchaseStatus.period_days}
|
||||||
|
giftMessage={purchaseStatus.gift_message}
|
||||||
|
isGift={purchaseStatus.is_gift}
|
||||||
|
isActivating={isActivating}
|
||||||
|
onActivate={handleActivate}
|
||||||
|
autoLoginToken={purchaseStatus.auto_login_token}
|
||||||
|
/>
|
||||||
|
{activationError && (
|
||||||
|
<p className="text-center text-sm text-error-400">{t('landing.activationFailed')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : isFailed ? (
|
||||||
|
<FailedState />
|
||||||
|
) : pollTimedOut ? (
|
||||||
|
<PollTimedOutState onRetry={handleRetryPoll} />
|
||||||
|
) : (
|
||||||
|
<PendingState />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1122
src/pages/QuickPurchase.tsx
Normal file
1122
src/pages/QuickPurchase.tsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -772,7 +772,9 @@ export default function SubscriptionPurchase() {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-dark-300">
|
<span className="text-dark-300">
|
||||||
{t('subscription.devices', { count: tariff.device_limit })}
|
{tariff.device_limit === 0
|
||||||
|
? '∞'
|
||||||
|
: t('subscription.devices', { count: tariff.device_limit })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{tariff.traffic_reset_mode &&
|
{tariff.traffic_reset_mode &&
|
||||||
@@ -959,7 +961,7 @@ export default function SubscriptionPurchase() {
|
|||||||
<div>
|
<div>
|
||||||
<span className="text-dark-500">{t('subscription.devices')}:</span>
|
<span className="text-dark-500">{t('subscription.devices')}:</span>
|
||||||
<span className="ml-2 text-dark-200">
|
<span className="ml-2 text-dark-200">
|
||||||
{selectedTariff.device_limit}
|
{selectedTariff.device_limit === 0 ? '∞' : selectedTariff.device_limit}
|
||||||
{selectedTariff.extra_devices_count > 0 && (
|
{selectedTariff.extra_devices_count > 0 && (
|
||||||
<span className="ml-1 text-xs text-accent-400">
|
<span className="ml-1 text-xs text-accent-400">
|
||||||
(+{selectedTariff.extra_devices_count})
|
(+{selectedTariff.extra_devices_count})
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ interface AuthState {
|
|||||||
checkAdminStatus: () => Promise<void>;
|
checkAdminStatus: () => Promise<void>;
|
||||||
loginWithTelegram: (initData: string) => Promise<void>;
|
loginWithTelegram: (initData: string) => Promise<void>;
|
||||||
loginWithTelegramWidget: (data: TelegramWidgetData) => Promise<void>;
|
loginWithTelegramWidget: (data: TelegramWidgetData) => Promise<void>;
|
||||||
|
loginWithTelegramOIDC: (idToken: string) => Promise<void>;
|
||||||
loginWithEmail: (email: string, password: string) => Promise<void>;
|
loginWithEmail: (email: string, password: string) => Promise<void>;
|
||||||
loginWithOAuth: (
|
loginWithOAuth: (
|
||||||
provider: string,
|
provider: string,
|
||||||
@@ -285,6 +286,21 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
await get().checkAdminStatus();
|
await get().checkAdminStatus();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
loginWithTelegramOIDC: async (idToken) => {
|
||||||
|
const campaignSlug = consumeCampaignSlug();
|
||||||
|
const referralCode = consumeReferralCode();
|
||||||
|
const response = await authApi.loginTelegramOIDC(idToken, campaignSlug, referralCode);
|
||||||
|
tokenStorage.setTokens(response.access_token, response.refresh_token);
|
||||||
|
set({
|
||||||
|
accessToken: response.access_token,
|
||||||
|
refreshToken: response.refresh_token,
|
||||||
|
user: response.user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
pendingCampaignBonus: response.campaign_bonus || null,
|
||||||
|
});
|
||||||
|
await get().checkAdminStatus();
|
||||||
|
},
|
||||||
|
|
||||||
loginWithEmail: async (email, password) => {
|
loginWithEmail: async (email, password) => {
|
||||||
const campaignSlug = consumeCampaignSlug();
|
const campaignSlug = consumeCampaignSlug();
|
||||||
const referralCode = consumeReferralCode();
|
const referralCode = consumeReferralCode();
|
||||||
|
|||||||
19
src/utils/api-error.ts
Normal file
19
src/utils/api-error.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export function getApiErrorMessage(err: unknown, fallback: string): string {
|
||||||
|
if (axios.isAxiosError(err)) {
|
||||||
|
const detail = err.response?.data?.detail;
|
||||||
|
if (typeof detail === 'string') return detail;
|
||||||
|
// Pydantic V2 validation errors: [{type, loc, msg, input, ctx}]
|
||||||
|
if (Array.isArray(detail) && detail.length > 0) {
|
||||||
|
return detail
|
||||||
|
.map((e: { loc?: (string | number)[]; msg?: string }) => {
|
||||||
|
const field = e.loc?.filter((s) => s !== 'body').join('.') ?? '';
|
||||||
|
return field ? `${field}: ${e.msg}` : (e.msg ?? '');
|
||||||
|
})
|
||||||
|
.join('; ');
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
8
src/utils/format.ts
Normal file
8
src/utils/format.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export function formatPrice(kopeks: number): string {
|
||||||
|
const rubles = kopeks / 100;
|
||||||
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'RUB',
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(rubles);
|
||||||
|
}
|
||||||
15
src/vite-env.d.ts
vendored
15
src/vite-env.d.ts
vendored
@@ -19,8 +19,23 @@ interface TelegramWebAppGlobal {
|
|||||||
offEvent?: (event: string, callback: () => void) => void;
|
offEvent?: (event: string, callback: () => void) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Telegram Login JS SDK — loaded from https://oauth.telegram.org/js/telegram-login.js */
|
||||||
|
interface TelegramLoginGlobal {
|
||||||
|
init: (
|
||||||
|
options: {
|
||||||
|
client_id: string | number;
|
||||||
|
request_access?: string[];
|
||||||
|
lang?: string;
|
||||||
|
},
|
||||||
|
callback: (data: { id_token?: string; user?: Record<string, unknown>; error?: string }) => void,
|
||||||
|
) => void;
|
||||||
|
open: () => void;
|
||||||
|
auth: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
Telegram?: {
|
Telegram?: {
|
||||||
WebApp?: TelegramWebAppGlobal;
|
WebApp?: TelegramWebAppGlobal;
|
||||||
|
Login?: TelegramLoginGlobal;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user