mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
66
src/App.tsx
66
src/App.tsx
@@ -9,6 +9,7 @@ import {
|
||||
ChannelSubscriptionScreen,
|
||||
BlacklistedScreen,
|
||||
} from './components/blocking';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import { PermissionRoute } from '@/components/auth/PermissionRoute';
|
||||
import { saveReturnUrl } from './utils/token';
|
||||
import { useAnalyticsCounters } from './hooks/useAnalyticsCounters';
|
||||
@@ -37,6 +38,9 @@ const Info = lazy(() => import('./pages/Info'));
|
||||
const Wheel = lazy(() => import('./pages/Wheel'));
|
||||
const Connection = lazy(() => import('./pages/Connection'));
|
||||
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 TopUpAmount = lazy(() => import('./pages/TopUpAmount'));
|
||||
const ConnectedAccounts = lazy(() => import('./pages/ConnectedAccounts'));
|
||||
@@ -104,6 +108,8 @@ const AdminRoleAssign = lazy(() => import('./pages/AdminRoleAssign'));
|
||||
const AdminPolicies = lazy(() => import('./pages/AdminPolicies'));
|
||||
const AdminPolicyEdit = lazy(() => import('./pages/AdminPolicyEdit'));
|
||||
const AdminAuditLog = lazy(() => import('./pages/AdminAuditLog'));
|
||||
const AdminLandings = lazy(() => import('./pages/AdminLandings'));
|
||||
const AdminLandingEditor = lazy(() => import('./pages/AdminLandingEditor'));
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
@@ -194,6 +200,36 @@ function App() {
|
||||
</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 */}
|
||||
<Route
|
||||
@@ -476,6 +512,36 @@ function App() {
|
||||
</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
|
||||
path="/admin/servers"
|
||||
element={
|
||||
|
||||
@@ -49,6 +49,20 @@ export const authApi = {
|
||||
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
|
||||
loginEmail: async (
|
||||
email: string,
|
||||
@@ -280,6 +294,12 @@ export const authApi = {
|
||||
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)
|
||||
getMergePreview: async (mergeToken: string): Promise<MergePreviewResponse> => {
|
||||
const response = await apiClient.get<MergePreviewResponse>(
|
||||
|
||||
@@ -23,6 +23,16 @@ export interface EmailAuthEnabled {
|
||||
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 {
|
||||
yandex_metrika_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)
|
||||
updateAnalyticsCounters: async (data: Partial<AnalyticsCounters>): Promise<AnalyticsCounters> => {
|
||||
const response = await apiClient.patch<AnalyticsCounters>('/cabinet/branding/analytics', data);
|
||||
|
||||
@@ -77,6 +77,7 @@ const AUTH_ENDPOINTS = [
|
||||
'/cabinet/auth/oauth/',
|
||||
'/cabinet/auth/merge/',
|
||||
'/cabinet/auth/account/link/server-complete',
|
||||
'/cabinet/landing/',
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
user_id: number;
|
||||
role_id: number;
|
||||
@@ -167,6 +177,13 @@ export const rbacApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// --- RBAC Users ---
|
||||
|
||||
getRbacUsers: async (): Promise<AdminWithRoles[]> => {
|
||||
const response = await apiClient.get<AdminWithRoles[]>(`${BASE}/users`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// --- Role Users ---
|
||||
|
||||
getRoleUsers: async (roleId: number): Promise<UserRoleAssignment[]> => {
|
||||
|
||||
@@ -85,6 +85,8 @@ export interface TariffDetail {
|
||||
daily_price_kopeks: number;
|
||||
// Режим сброса трафика
|
||||
traffic_reset_mode: string | null; // 'DAY', 'WEEK', 'MONTH', 'NO_RESET', null = глобальная настройка
|
||||
// Внешний сквад RemnaWave
|
||||
external_squad_uuid: string | null;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
}
|
||||
@@ -121,6 +123,14 @@ export interface TariffCreateRequest {
|
||||
daily_price_kopeks?: number;
|
||||
// Режим сброса трафика
|
||||
traffic_reset_mode?: string | null;
|
||||
// Внешний сквад RemnaWave
|
||||
external_squad_uuid?: string | null;
|
||||
}
|
||||
|
||||
export interface ExternalSquadInfo {
|
||||
uuid: string;
|
||||
name: string;
|
||||
members_count: number;
|
||||
}
|
||||
|
||||
export interface TariffUpdateRequest {
|
||||
@@ -156,6 +166,8 @@ export interface TariffUpdateRequest {
|
||||
daily_price_kopeks?: number;
|
||||
// Режим сброса трафика
|
||||
traffic_reset_mode?: string | null;
|
||||
// Внешний сквад RemnaWave
|
||||
external_squad_uuid?: string | null;
|
||||
}
|
||||
|
||||
export interface TariffToggleResponse {
|
||||
@@ -249,4 +261,10 @@ export const tariffsApi = {
|
||||
const response = await apiClient.get('/cabinet/admin/payment-methods/promo-groups');
|
||||
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 { useQuery } from '@tanstack/react-query';
|
||||
import { brandingApi, type TelegramWidgetConfig } from '../api/branding';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
interface TelegramLoginButtonProps {
|
||||
botUsername: string;
|
||||
referralCode?: string;
|
||||
}
|
||||
|
||||
export default function TelegramLoginButton({
|
||||
botUsername,
|
||||
referralCode,
|
||||
}: TelegramLoginButtonProps) {
|
||||
export default function TelegramLoginButton({ referralCode }: TelegramLoginButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
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(() => {
|
||||
if (!containerRef.current || !botUsername) return;
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Clear previous widget using safe DOM API
|
||||
while (containerRef.current.firstChild) {
|
||||
containerRef.current.removeChild(containerRef.current.firstChild);
|
||||
handleOIDCCallbackRef.current = async (data: { id_token?: string; error?: string }) => {
|
||||
if (!mountedRef.current) return;
|
||||
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`;
|
||||
|
||||
// Create script element for Telegram Login Widget
|
||||
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-size', 'large');
|
||||
script.setAttribute('data-radius', '8');
|
||||
script.setAttribute('data-size', widgetConfig.size);
|
||||
script.setAttribute('data-radius', String(widgetConfig.radius));
|
||||
script.setAttribute('data-userpic', String(widgetConfig.userpic));
|
||||
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;
|
||||
|
||||
containerRef.current.appendChild(script);
|
||||
}, [botUsername]);
|
||||
container.appendChild(script);
|
||||
|
||||
return () => {
|
||||
while (container.firstChild) {
|
||||
container.removeChild(container.firstChild);
|
||||
}
|
||||
};
|
||||
}, [isOIDC, botUsername, widgetConfig]);
|
||||
|
||||
if (!botUsername || botUsername === 'your_bot') {
|
||||
return (
|
||||
@@ -48,10 +147,35 @@ export default function TelegramLoginButton({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
{/* Telegram Widget will be inserted here */}
|
||||
<div ref={containerRef} className="flex justify-center" />
|
||||
{/* OIDC mode: custom button that opens popup */}
|
||||
{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">
|
||||
<p className="mb-2 text-xs text-gray-500">{t('auth.orOpenInApp')}</p>
|
||||
<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',
|
||||
'CONNECT_BUTTON',
|
||||
'MINIAPP',
|
||||
'TELEGRAM_WIDGET',
|
||||
'TELEGRAM_OIDC',
|
||||
'HAPP',
|
||||
'SKIP',
|
||||
'ADDITIONAL',
|
||||
|
||||
@@ -12,6 +12,8 @@ export * from './FavoritesTab';
|
||||
export * from './SettingsTab';
|
||||
export * from './SettingsMobileTabs';
|
||||
export * from './SettingsSearch';
|
||||
export * from './LocaleTabs';
|
||||
export * from './LocalizedInput';
|
||||
|
||||
// Constants and utils
|
||||
export * from './constants';
|
||||
|
||||
@@ -236,15 +236,24 @@ export default function SubscriptionCardActive({
|
||||
{t('dashboard.connectDevice')}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-dark-50/30">
|
||||
{t('dashboard.devicesOfMax', {
|
||||
used: connectedDevices,
|
||||
max: subscription.device_limit,
|
||||
})}
|
||||
{subscription.device_limit === 0
|
||||
? t('dashboard.devicesConnectedUnlimited', { used: connectedDevices })
|
||||
: t('dashboard.devicesOfMax', {
|
||||
used: connectedDevices,
|
||||
max: subscription.device_limit,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{Array.from({ length: subscription.device_limit }, (_, i) => (
|
||||
<div
|
||||
|
||||
@@ -175,7 +175,10 @@ export default function TrialOfferCard({
|
||||
value: trialInfo.traffic_limit_gb === 0 ? '∞' : String(trialInfo.traffic_limit_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) => (
|
||||
<div key={i} className="text-center">
|
||||
<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",
|
||||
"devicesConnected": "{{count}} connected",
|
||||
"devicesOfMax": "{{used}} of {{max}} connected",
|
||||
"devicesConnectedUnlimited": "{{used}} connected · unlimited",
|
||||
"devicesShort": "dev.",
|
||||
"trafficUsage": "{{used}} / {{limit}} GB",
|
||||
"trafficUsageTitle": "Traffic Usage",
|
||||
"usedTraffic": "used {{amount}}",
|
||||
"usedSuffix": "used",
|
||||
"unlimited": "Unlimited",
|
||||
"unlimitedTraffic": "Unlimited traffic",
|
||||
"tariff": "Tariff",
|
||||
"validUntil": "until {{date}}",
|
||||
"remaining": "Remaining",
|
||||
@@ -1043,7 +1043,8 @@
|
||||
"roleAssign": "Role Assignment",
|
||||
"policies": "Access Policies",
|
||||
"auditLog": "Audit Log",
|
||||
"salesStats": "Sales Statistics"
|
||||
"salesStats": "Sales Statistics",
|
||||
"landings": "Landings"
|
||||
},
|
||||
"panel": {
|
||||
"title": "Admin Panel",
|
||||
@@ -1076,7 +1077,8 @@
|
||||
"roleAssignDesc": "Assign and revoke user roles",
|
||||
"policiesDesc": "Configure ABAC access policies",
|
||||
"auditLogDesc": "Review system activity and access history",
|
||||
"salesStatsDesc": "Sales analytics and trends"
|
||||
"salesStatsDesc": "Sales analytics and trends",
|
||||
"landingsDesc": "Quick purchase landing pages"
|
||||
},
|
||||
"salesStats": {
|
||||
"title": "Sales Statistics",
|
||||
@@ -1679,7 +1681,20 @@
|
||||
"projectName": "Project name",
|
||||
"quickPresets": "Quick presets",
|
||||
"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": {
|
||||
"accentColor": "Accent color",
|
||||
@@ -1908,6 +1923,10 @@
|
||||
"noPeriodsHint": "No added periods. Add at least one period.",
|
||||
"daysShort": "d.",
|
||||
"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.",
|
||||
"noServersAvailable": "No servers available",
|
||||
"promoGroupsTitle": "Promo Groups",
|
||||
@@ -2012,6 +2031,7 @@
|
||||
"subtitle": "Manage advertising links",
|
||||
"createButton": "Create",
|
||||
"noData": "No ad campaigns",
|
||||
"loadMore": "Load more",
|
||||
"bonusType": {
|
||||
"balance": "Balance",
|
||||
"subscription": "Subscription",
|
||||
@@ -2866,6 +2886,7 @@
|
||||
"apps": "Apps",
|
||||
"email_templates": "Email templates",
|
||||
"pinned_messages": "Pinned messages",
|
||||
"landings": "Landing pages",
|
||||
"updates": "Updates"
|
||||
},
|
||||
"permissionActions": {
|
||||
@@ -3077,6 +3098,7 @@
|
||||
"filters": {
|
||||
"title": "Filters",
|
||||
"active": "Active",
|
||||
"user": "User",
|
||||
"action": "Action",
|
||||
"actionPlaceholder": "Search by action...",
|
||||
"resource": "Resource type",
|
||||
@@ -3138,6 +3160,74 @@
|
||||
"loadFailed": "Failed to load audit log",
|
||||
"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": {
|
||||
@@ -3865,5 +3955,89 @@
|
||||
"error": "Failed to merge accounts. Please try again later.",
|
||||
"expiresIn": "Expires in {{minutes}}",
|
||||
"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": "تخصیص نقش",
|
||||
"policies": "سیاستهای دسترسی",
|
||||
"auditLog": "گزارش بازرسی",
|
||||
"salesStats": "آمار فروش"
|
||||
"salesStats": "آمار فروش",
|
||||
"landings": "صفحات فرود"
|
||||
},
|
||||
"panel": {
|
||||
"title": "پنل مدیریت",
|
||||
@@ -899,7 +900,8 @@
|
||||
"roleAssignDesc": "تخصیص و لغو نقشهای کاربران",
|
||||
"policiesDesc": "تنظیم سیاستهای کنترل دسترسی ABAC",
|
||||
"auditLogDesc": "بررسی فعالیت سیستم و تاریخچه دسترسی",
|
||||
"salesStatsDesc": "تحلیل فروش و روندها"
|
||||
"salesStatsDesc": "تحلیل فروش و روندها",
|
||||
"landingsDesc": "صفحات خرید سریع"
|
||||
},
|
||||
"salesStats": {
|
||||
"title": "آمار فروش",
|
||||
@@ -1339,7 +1341,20 @@
|
||||
"projectName": "نام پروژه",
|
||||
"quickPresets": "پیشتنظیمهای سریع",
|
||||
"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": {
|
||||
"color": "رنگ دکمه",
|
||||
@@ -1552,6 +1567,10 @@
|
||||
"noPeriodsHint": "هیچ دورهای اضافه نشده. حداقل یک دوره اضافه کنید.",
|
||||
"daysShort": "روز",
|
||||
"serversTitle": "سرورها",
|
||||
"externalSquadTitle": "گروه خارجی",
|
||||
"externalSquadHint": "یک گروه خارجی RemnaWave برای کاربران این تعرفه تعیین کنید. هنگام ایجاد اشتراک، کاربر به طور خودکار به گروه انتخاب شده اضافه میشود.",
|
||||
"noExternalSquad": "بدون گروه خارجی",
|
||||
"externalSquadUsers": "کاربران",
|
||||
"serversTabHint": "سرورهای موجود در این تعرفه را انتخاب کنید.",
|
||||
"noServersAvailable": "هیچ سروری در دسترس نیست",
|
||||
"promoGroupsTitle": "گروههای تبلیغاتی",
|
||||
@@ -2599,6 +2618,7 @@
|
||||
"apps": "برنامهها",
|
||||
"email_templates": "قالبهای ایمیل",
|
||||
"pinned_messages": "پیامهای سنجاقشده",
|
||||
"landings": "صفحات فرود",
|
||||
"updates": "بهروزرسانیها"
|
||||
},
|
||||
"permissionActions": {
|
||||
@@ -2806,6 +2826,7 @@
|
||||
"filters": {
|
||||
"title": "فیلترها",
|
||||
"active": "فعال",
|
||||
"user": "کاربر",
|
||||
"action": "عملیات",
|
||||
"actionPlaceholder": "جستجو بر اساس عملیات...",
|
||||
"resource": "نوع منبع",
|
||||
@@ -2864,6 +2885,63 @@
|
||||
"loadFailed": "بارگذاری گزارش بازرسی ناموفق بود",
|
||||
"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": {
|
||||
@@ -3416,5 +3494,81 @@
|
||||
"error": "ادغام ناموفق بود. لطفاً بعداً دوباره تلاش کنید.",
|
||||
"expiresIn": "{{minutes}} دقیقه باقی مانده",
|
||||
"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": "Подключить устройство",
|
||||
"devicesConnected": "{{count}} подключено",
|
||||
"devicesOfMax": "{{used}} из {{max}} подключено",
|
||||
"devicesConnectedUnlimited": "{{used}} подключено · без лимита",
|
||||
"devicesShort": "устр.",
|
||||
"trafficUsage": "{{used}} / {{limit}} ГБ",
|
||||
"trafficUsageTitle": "Расход трафика",
|
||||
"usedTraffic": "использовано {{amount}}",
|
||||
"usedSuffix": "израсходовано",
|
||||
"unlimited": "Безлимит",
|
||||
"unlimitedTraffic": "Безлимитный трафик",
|
||||
"tariff": "Тариф",
|
||||
"validUntil": "до {{date}}",
|
||||
"remaining": "Осталось",
|
||||
@@ -1064,7 +1064,8 @@
|
||||
"roleAssign": "Назначение ролей",
|
||||
"policies": "Политики доступа",
|
||||
"auditLog": "Журнал аудита",
|
||||
"salesStats": "Статистика продаж"
|
||||
"salesStats": "Статистика продаж",
|
||||
"landings": "Лендинги"
|
||||
},
|
||||
"panel": {
|
||||
"title": "Панель администратора",
|
||||
@@ -1097,7 +1098,8 @@
|
||||
"roleAssignDesc": "Назначение и отзыв ролей пользователей",
|
||||
"policiesDesc": "Настройка политик контроля доступа ABAC",
|
||||
"auditLogDesc": "Просмотр активности системы и истории доступа",
|
||||
"salesStatsDesc": "Аналитика продаж и тренды"
|
||||
"salesStatsDesc": "Аналитика продаж и тренды",
|
||||
"landingsDesc": "Страницы быстрой покупки"
|
||||
},
|
||||
"salesStats": {
|
||||
"title": "Статистика продаж",
|
||||
@@ -1760,6 +1762,10 @@
|
||||
"Subscription Show Devices": "Показывать устройства",
|
||||
"Telegram Stars Enabled": "Stars включены",
|
||||
"Telegram Stars Rate Rub": "Курс Stars (₽)",
|
||||
"Telegram Widget Size": "Размер виджета",
|
||||
"Telegram Widget Radius": "Скругление углов",
|
||||
"Telegram Widget Userpic": "Показывать аватар",
|
||||
"Telegram Widget Request Access": "Запрос доступа",
|
||||
"Tribute Api Key": "API ключ",
|
||||
"Tribute Donate Link": "Ссылка доната",
|
||||
"Tribute Enabled": "Tribute включен",
|
||||
@@ -2148,7 +2154,10 @@
|
||||
"External Admin Token": "Токен внешней админки",
|
||||
"External Admin Token Bot Id": "ID бота",
|
||||
"Bot Username": "Username бота",
|
||||
"Debug": "Отладка"
|
||||
"Debug": "Отладка",
|
||||
"telegramOidcEnabled": "OIDC включён",
|
||||
"telegramOidcClientId": "Client ID (ID бота)",
|
||||
"telegramOidcClientSecret": "Client Secret"
|
||||
},
|
||||
"categories": {
|
||||
"PAYMENT": "Платежи",
|
||||
@@ -2162,6 +2171,8 @@
|
||||
"PAL24": "PAL24",
|
||||
"WATA": "Wata",
|
||||
"TELEGRAM": "Telegram",
|
||||
"TELEGRAM_WIDGET": "Telegram Login Widget",
|
||||
"TELEGRAM_OIDC": "Telegram Login (OIDC)",
|
||||
"SUBSCRIPTIONS_CORE": "Основные",
|
||||
"SIMPLE_SUBSCRIPTION": "Простая подписка",
|
||||
"PERIODS": "Периоды",
|
||||
@@ -2426,6 +2437,10 @@
|
||||
"noPeriodsHint": "Нет добавленных периодов. Добавьте хотя бы один период.",
|
||||
"daysShort": "дн.",
|
||||
"serversTitle": "Серверы",
|
||||
"externalSquadTitle": "Внешний сквад",
|
||||
"externalSquadHint": "Назначьте внешний сквад RemnaWave для пользователей этого тарифа. При создании подписки пользователь будет автоматически добавлен в выбранный сквад.",
|
||||
"noExternalSquad": "Без внешнего сквада",
|
||||
"externalSquadUsers": "пользователей",
|
||||
"serversTabHint": "Выберите серверы, доступные на этом тарифе.",
|
||||
"noServersAvailable": "Нет доступных серверов",
|
||||
"promoGroupsTitle": "Промо-группы",
|
||||
@@ -2530,6 +2545,7 @@
|
||||
"subtitle": "Управление рекламными ссылками",
|
||||
"createButton": "Создать",
|
||||
"noData": "Нет рекламных кампаний",
|
||||
"loadMore": "Загрузить ещё",
|
||||
"bonusType": {
|
||||
"balance": "Баланс",
|
||||
"subscription": "Подписка",
|
||||
@@ -3414,6 +3430,7 @@
|
||||
"apps": "Приложения",
|
||||
"email_templates": "Шаблоны писем",
|
||||
"pinned_messages": "Закреплённые сообщения",
|
||||
"landings": "Лендинги",
|
||||
"updates": "Обновления"
|
||||
},
|
||||
"permissionActions": {
|
||||
@@ -3629,6 +3646,7 @@
|
||||
"filters": {
|
||||
"title": "Фильтры",
|
||||
"active": "Активны",
|
||||
"user": "Пользователь",
|
||||
"action": "Действие",
|
||||
"actionPlaceholder": "Поиск по действию...",
|
||||
"resource": "Тип ресурса",
|
||||
@@ -3693,6 +3711,74 @@
|
||||
"loadFailed": "Не удалось загрузить журнал аудита",
|
||||
"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": {
|
||||
@@ -4428,5 +4514,91 @@
|
||||
"error": "Ошибка при объединении. Попробуйте позже.",
|
||||
"expiresIn": "Действует ещё {{minutes}}",
|
||||
"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": "角色分配",
|
||||
"policies": "访问策略",
|
||||
"auditLog": "审计日志",
|
||||
"salesStats": "销售统计"
|
||||
"salesStats": "销售统计",
|
||||
"landings": "落地页"
|
||||
},
|
||||
"panel": {
|
||||
"title": "管理面板",
|
||||
@@ -899,7 +900,8 @@
|
||||
"roleAssignDesc": "分配和撤销用户角色",
|
||||
"policiesDesc": "配置ABAC访问控制策略",
|
||||
"auditLogDesc": "查看系统活动和访问历史",
|
||||
"salesStatsDesc": "销售分析与趋势"
|
||||
"salesStatsDesc": "销售分析与趋势",
|
||||
"landingsDesc": "快速购买落地页"
|
||||
},
|
||||
"salesStats": {
|
||||
"title": "销售统计",
|
||||
@@ -1377,7 +1379,20 @@
|
||||
"projectName": "项目名称",
|
||||
"quickPresets": "快速预设",
|
||||
"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": {
|
||||
"color": "按钮颜色",
|
||||
@@ -1589,6 +1604,10 @@
|
||||
"noPeriodsHint": "没有添加的周期。请至少添加一个周期。",
|
||||
"daysShort": "天",
|
||||
"serversTitle": "服务器",
|
||||
"externalSquadTitle": "外部小组",
|
||||
"externalSquadHint": "为此套餐的用户分配RemnaWave外部小组。创建订阅时,用户将自动添加到所选小组。",
|
||||
"noExternalSquad": "无外部小组",
|
||||
"externalSquadUsers": "用户",
|
||||
"serversTabHint": "选择此套餐上可用的服务器。",
|
||||
"noServersAvailable": "没有可用的服务器",
|
||||
"promoGroupsTitle": "促销组",
|
||||
@@ -2598,6 +2617,7 @@
|
||||
"apps": "应用",
|
||||
"email_templates": "邮件模板",
|
||||
"pinned_messages": "置顶消息",
|
||||
"landings": "落地页",
|
||||
"updates": "更新"
|
||||
},
|
||||
"permissionActions": {
|
||||
@@ -2805,6 +2825,7 @@
|
||||
"filters": {
|
||||
"title": "筛选",
|
||||
"active": "已激活",
|
||||
"user": "用户",
|
||||
"action": "操作",
|
||||
"actionPlaceholder": "按操作搜索...",
|
||||
"resource": "资源类型",
|
||||
@@ -2863,6 +2884,63 @@
|
||||
"loadFailed": "加载审计日志失败",
|
||||
"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": {
|
||||
@@ -3415,5 +3493,81 @@
|
||||
"error": "合并失败,请稍后重试。",
|
||||
"expiresIn": "剩余 {{minutes}} 分钟",
|
||||
"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 { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -84,6 +84,7 @@ const RESOURCE_TYPES = [
|
||||
'users',
|
||||
'tickets',
|
||||
'stats',
|
||||
'sales_stats',
|
||||
'broadcasts',
|
||||
'tariffs',
|
||||
'promocodes',
|
||||
@@ -106,6 +107,7 @@ const RESOURCE_TYPES = [
|
||||
'apps',
|
||||
'email_templates',
|
||||
'pinned_messages',
|
||||
'landings',
|
||||
'updates',
|
||||
] as const;
|
||||
|
||||
@@ -116,6 +118,7 @@ const PAGE_SIZE_OPTIONS = [20, 50, 100] as const;
|
||||
const AUTO_REFRESH_INTERVAL = 30_000;
|
||||
|
||||
interface FiltersState {
|
||||
userId: string;
|
||||
action: string;
|
||||
resource: string;
|
||||
status: string;
|
||||
@@ -124,6 +127,7 @@ interface FiltersState {
|
||||
}
|
||||
|
||||
const INITIAL_FILTERS: FiltersState = {
|
||||
userId: '',
|
||||
action: '',
|
||||
resource: '',
|
||||
status: '',
|
||||
@@ -431,9 +435,6 @@ export default function AdminAuditLog() {
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
|
||||
// Auto-refresh interval ref
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Build query params
|
||||
const queryParams = useMemo((): AuditLogFilters => {
|
||||
const params: AuditLogFilters = {
|
||||
@@ -441,6 +442,10 @@ export default function AdminAuditLog() {
|
||||
offset: page * pageSize,
|
||||
};
|
||||
|
||||
const parsedUserId = parseInt(appliedFilters.userId, 10);
|
||||
if (!isNaN(parsedUserId) && parsedUserId > 0) {
|
||||
params.user_id = parsedUserId;
|
||||
}
|
||||
if (appliedFilters.action.trim()) {
|
||||
params.action = appliedFilters.action.trim();
|
||||
}
|
||||
@@ -467,21 +472,12 @@ export default function AdminAuditLog() {
|
||||
refetchInterval: autoRefresh ? AUTO_REFRESH_INTERVAL : false,
|
||||
});
|
||||
|
||||
// Auto-refresh visual indicator
|
||||
useEffect(() => {
|
||||
if (autoRefresh) {
|
||||
intervalRef.current = setInterval(() => {
|
||||
// The visual indicator updates are driven by isFetching from react-query
|
||||
}, AUTO_REFRESH_INTERVAL);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [autoRefresh]);
|
||||
// RBAC users for filter chips
|
||||
const { data: rbacUsers } = useQuery({
|
||||
queryKey: ['rbac-users'],
|
||||
queryFn: () => rbacApi.getRbacUsers(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const entries = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
@@ -517,8 +513,11 @@ export default function AdminAuditLog() {
|
||||
setExporting(true);
|
||||
try {
|
||||
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.resource) exportParams.resource_type = appliedFilters.resource;
|
||||
if (appliedFilters.status) exportParams.status = appliedFilters.status;
|
||||
if (appliedFilters.dateFrom) exportParams.date_from = appliedFilters.dateFrom;
|
||||
if (appliedFilters.dateTo) exportParams.date_to = appliedFilters.dateTo;
|
||||
|
||||
@@ -548,6 +547,7 @@ export default function AdminAuditLog() {
|
||||
|
||||
const hasActiveFilters = useMemo(() => {
|
||||
return (
|
||||
appliedFilters.userId.trim() !== '' ||
|
||||
appliedFilters.action.trim() !== '' ||
|
||||
appliedFilters.resource !== '' ||
|
||||
appliedFilters.status !== '' ||
|
||||
@@ -649,6 +649,51 @@ export default function AdminAuditLog() {
|
||||
{filtersOpen && (
|
||||
<div className="border-t border-dark-700 p-4">
|
||||
<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 */}
|
||||
<div>
|
||||
<label
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
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 i18n from '../i18n';
|
||||
import { campaignsApi, CampaignListItem, CampaignBonusType } from '../api/campaigns';
|
||||
import { PlusIcon, EditIcon, TrashIcon, CheckIcon, XIcon, ChartIcon } from '../components/icons';
|
||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
// Bonus type labels and colors
|
||||
const bonusTypeConfig: Record<
|
||||
CampaignBonusType,
|
||||
@@ -69,9 +71,20 @@ export default function AdminCampaigns() {
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
|
||||
|
||||
// Queries
|
||||
const { data: campaignsData, isLoading } = useQuery({
|
||||
const {
|
||||
data: campaignsData,
|
||||
isLoading,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useInfiniteQuery({
|
||||
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({
|
||||
@@ -96,7 +109,7 @@ export default function AdminCampaigns() {
|
||||
},
|
||||
});
|
||||
|
||||
const campaigns = campaignsData?.campaigns || [];
|
||||
const campaigns = campaignsData?.pages.flatMap((p) => p.campaigns) ?? [];
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
@@ -261,6 +274,21 @@ export default function AdminCampaigns() {
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
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 {
|
||||
to: string;
|
||||
icon: React.ReactNode;
|
||||
@@ -499,6 +509,13 @@ export default function AdminPanel() {
|
||||
description: t('admin.panel.paymentMethodsDesc'),
|
||||
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,
|
||||
PeriodPrice,
|
||||
ServerInfo,
|
||||
ExternalSquadInfo,
|
||||
} from '../api/tariffs';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
|
||||
@@ -105,6 +106,7 @@ export default function AdminTariffCreate() {
|
||||
const [tierLevel, setTierLevel] = useState<number | ''>(1);
|
||||
const [periodPrices, setPeriodPrices] = useState<PeriodPrice[]>([]);
|
||||
const [selectedSquads, setSelectedSquads] = useState<string[]>([]);
|
||||
const [selectedExternalSquad, setSelectedExternalSquad] = useState<string | null>(null);
|
||||
const [selectedPromoGroups, setSelectedPromoGroups] = useState<number[]>([]);
|
||||
const [dailyPriceKopeks, setDailyPriceKopeks] = useState<number | ''>(0);
|
||||
|
||||
@@ -138,6 +140,12 @@ export default function AdminTariffCreate() {
|
||||
queryFn: () => tariffsApi.getAvailableServers(),
|
||||
});
|
||||
|
||||
// Fetch external squads
|
||||
const { data: externalSquads = [] } = useQuery({
|
||||
queryKey: ['admin-tariffs-external-squads'],
|
||||
queryFn: () => tariffsApi.getAvailableExternalSquads(),
|
||||
});
|
||||
|
||||
// Fetch promo groups
|
||||
const { data: promoGroups = [] } = useQuery({
|
||||
queryKey: ['admin-tariffs-promo-groups'],
|
||||
@@ -165,6 +173,7 @@ export default function AdminTariffCreate() {
|
||||
setTierLevel(data.tier_level || 1);
|
||||
setPeriodPrices(data.period_prices?.length ? data.period_prices : []);
|
||||
setSelectedSquads(data.allowed_squads || []);
|
||||
setSelectedExternalSquad(data.external_squad_uuid || null);
|
||||
setSelectedPromoGroups(
|
||||
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),
|
||||
period_prices: isDaily ? [] : periodPrices.filter((p) => p.price_kopeks >= 0),
|
||||
allowed_squads: selectedSquads,
|
||||
external_squad_uuid: selectedExternalSquad || null,
|
||||
promo_group_ids: selectedPromoGroups.length > 0 ? selectedPromoGroups : undefined,
|
||||
traffic_topup_enabled: trafficTopupEnabled,
|
||||
traffic_topup_packages: trafficTopupPackages,
|
||||
@@ -660,6 +670,77 @@ export default function AdminTariffCreate() {
|
||||
|
||||
{activeTab === 'servers' && (
|
||||
<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 */}
|
||||
<div className="card space-y-4">
|
||||
<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 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-size', 'small');
|
||||
script.setAttribute('data-radius', '8');
|
||||
|
||||
@@ -485,10 +485,7 @@ export default function Login() {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<TelegramLoginButton
|
||||
botUsername={botUsername}
|
||||
referralCode={referralCode || undefined}
|
||||
/>
|
||||
<TelegramLoginButton referralCode={referralCode || undefined} />
|
||||
)}
|
||||
</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>
|
||||
<span className="text-dark-300">
|
||||
{t('subscription.devices', { count: tariff.device_limit })}
|
||||
{tariff.device_limit === 0
|
||||
? '∞'
|
||||
: t('subscription.devices', { count: tariff.device_limit })}
|
||||
</span>
|
||||
</div>
|
||||
{tariff.traffic_reset_mode &&
|
||||
@@ -959,7 +961,7 @@ export default function SubscriptionPurchase() {
|
||||
<div>
|
||||
<span className="text-dark-500">{t('subscription.devices')}:</span>
|
||||
<span className="ml-2 text-dark-200">
|
||||
{selectedTariff.device_limit}
|
||||
{selectedTariff.device_limit === 0 ? '∞' : selectedTariff.device_limit}
|
||||
{selectedTariff.extra_devices_count > 0 && (
|
||||
<span className="ml-1 text-xs text-accent-400">
|
||||
(+{selectedTariff.extra_devices_count})
|
||||
|
||||
@@ -37,6 +37,7 @@ interface AuthState {
|
||||
checkAdminStatus: () => Promise<void>;
|
||||
loginWithTelegram: (initData: string) => Promise<void>;
|
||||
loginWithTelegramWidget: (data: TelegramWidgetData) => Promise<void>;
|
||||
loginWithTelegramOIDC: (idToken: string) => Promise<void>;
|
||||
loginWithEmail: (email: string, password: string) => Promise<void>;
|
||||
loginWithOAuth: (
|
||||
provider: string,
|
||||
@@ -285,6 +286,21 @@ export const useAuthStore = create<AuthState>()(
|
||||
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) => {
|
||||
const campaignSlug = consumeCampaignSlug();
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
Telegram?: {
|
||||
WebApp?: TelegramWebAppGlobal;
|
||||
Login?: TelegramLoginGlobal;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user