diff --git a/src/App.tsx b/src/App.tsx index 26e0dad..f7ace2c 100644 --- a/src/App.tsx +++ b/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() { } /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> {/* Protected routes */} } /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> => { + const response = await apiClient.post('/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 => { + const response = await apiClient.post('/cabinet/auth/login/auto', { token }); + return response.data; + }, + // Account merge (no JWT required) getMergePreview: async (mergeToken: string): Promise => { const response = await apiClient.get( diff --git a/src/api/branding.ts b/src/api/branding.ts index 1d1e013..235d511 100644 --- a/src/api/branding.ts +++ b/src/api/branding.ts @@ -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 => { + try { + const response = await apiClient.get( + '/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): Promise => { const response = await apiClient.patch('/cabinet/branding/analytics', data); diff --git a/src/api/client.ts b/src/api/client.ts index 1f27460..d200982 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -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 { diff --git a/src/api/landings.ts b/src/api/landings.ts new file mode 100644 index 0000000..db99399 --- /dev/null +++ b/src/api/landings.ts @@ -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 | 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; + +/** 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 = { + 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; + 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 | 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; + payment_methods?: AdminLandingPaymentMethod[]; + gift_enabled?: boolean; + custom_css?: string; + meta_title?: LocaleDict; + meta_description?: LocaleDict; + discount_percent?: number | null; + discount_overrides?: Record | null; + discount_starts_at?: string | null; + discount_ends_at?: string | null; + discount_badge_text?: LocaleDict | null; +} + +export type LandingUpdateRequest = Partial; + +/** + * 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 => { + const params = lang ? `?lang=${lang}` : ''; + const response = await apiClient.get(`/cabinet/landing/${slug}${params}`); + return response.data; + }, + + createPurchase: async (slug: string, data: PurchaseRequest): Promise => { + const response = await apiClient.post(`/cabinet/landing/${slug}/purchase`, data); + return response.data; + }, + + getPurchaseStatus: async (token: string): Promise => { + const response = await apiClient.get(`/cabinet/landing/purchase/${token}`); + return response.data; + }, + + activatePurchase: async (token: string): Promise => { + const response = await apiClient.post(`/cabinet/landing/activate/${token}`); + return response.data; + }, +}; + +// ============================================================ +// Admin API +// ============================================================ + +export const adminLandingsApi = { + list: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/landings'); + return response.data; + }, + + get: async (id: number): Promise => { + const response = await apiClient.get(`/cabinet/admin/landings/${id}`); + return response.data; + }, + + create: async (data: LandingCreateRequest): Promise => { + const response = await apiClient.post('/cabinet/admin/landings', data); + return response.data; + }, + + update: async (id: number, data: LandingUpdateRequest): Promise => { + 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 => { + const response = await apiClient.post(`/cabinet/admin/landings/${id}/toggle`); + return response.data; + }, + + reorder: async (landingIds: number[]): Promise => { + await apiClient.put('/cabinet/admin/landings/order', { landing_ids: landingIds }); + }, +}; diff --git a/src/api/rbac.ts b/src/api/rbac.ts index a0af13b..39cbd45 100644 --- a/src/api/rbac.ts +++ b/src/api/rbac.ts @@ -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 => { + const response = await apiClient.get(`${BASE}/users`); + return response.data; + }, + // --- Role Users --- getRoleUsers: async (roleId: number): Promise => { diff --git a/src/api/tariffs.ts b/src/api/tariffs.ts index 5e4234f..8664f24 100644 --- a/src/api/tariffs.ts +++ b/src/api/tariffs.ts @@ -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 => { + const response = await apiClient.get('/cabinet/admin/tariffs/available-external-squads'); + return response.data; + }, }; diff --git a/src/components/TelegramLoginButton.tsx b/src/components/TelegramLoginButton.tsx index ebbd986..08ebd8d 100644 --- a/src/components/TelegramLoginButton.tsx +++ b/src/components/TelegramLoginButton.tsx @@ -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(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({ + 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 (
- {/* Telegram Widget will be inserted here */} -
+ {/* OIDC mode: custom button that opens popup */} + {isOIDC ? ( +
+ + {oidcError &&

{oidcError}

} +
+ ) : ( + /* Legacy widget mode: iframe-based widget */ +
+ )} - {/* Fallback link for mobile */}

{t('auth.orOpenInApp')}

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 ( +
+
+ {SUPPORTED_LOCALES.map((locale) => { + const meta = LOCALE_META[locale]; + const isActive = locale === activeLocale; + const filled = hasContent(locale); + const isRtl = meta.rtl; + + return ( + + ); + })} +
+

{t('admin.landings.localeHint')}

+
+ ); +} diff --git a/src/components/admin/LocalizedInput.tsx b/src/components/admin/LocalizedInput.tsx new file mode 100644 index 0000000..faa3edd --- /dev/null +++ b/src/components/admin/LocalizedInput.tsx @@ -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 ( +