mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +00:00
26
src/App.tsx
26
src/App.tsx
@@ -36,6 +36,8 @@ const Contests = lazy(() => import('./pages/Contests'));
|
|||||||
const Polls = lazy(() => import('./pages/Polls'));
|
const Polls = lazy(() => import('./pages/Polls'));
|
||||||
const Info = lazy(() => import('./pages/Info'));
|
const Info = lazy(() => import('./pages/Info'));
|
||||||
const Wheel = lazy(() => import('./pages/Wheel'));
|
const Wheel = lazy(() => import('./pages/Wheel'));
|
||||||
|
const GiftSubscription = lazy(() => import('./pages/GiftSubscription'));
|
||||||
|
const GiftResult = lazy(() => import('./pages/GiftResult'));
|
||||||
const Connection = lazy(() => import('./pages/Connection'));
|
const Connection = lazy(() => import('./pages/Connection'));
|
||||||
const ConnectionQR = lazy(() => import('./pages/ConnectionQR'));
|
const ConnectionQR = lazy(() => import('./pages/ConnectionQR'));
|
||||||
const QuickPurchase = lazy(() => import('./pages/QuickPurchase'));
|
const QuickPurchase = lazy(() => import('./pages/QuickPurchase'));
|
||||||
@@ -420,6 +422,30 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/gift"
|
||||||
|
element={
|
||||||
|
<ErrorBoundary level="app">
|
||||||
|
<ProtectedRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<GiftSubscription />
|
||||||
|
</LazyPage>
|
||||||
|
</ProtectedRoute>
|
||||||
|
</ErrorBoundary>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/gift/result"
|
||||||
|
element={
|
||||||
|
<ErrorBoundary level="app">
|
||||||
|
<ProtectedRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<GiftResult />
|
||||||
|
</LazyPage>
|
||||||
|
</ProtectedRoute>
|
||||||
|
</ErrorBoundary>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/connection/qr"
|
path="/connection/qr"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -248,10 +248,11 @@ export const authApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Link Telegram account (Mini App initData or Login Widget data)
|
// Link Telegram account (Mini App initData, OIDC id_token, or Login Widget data)
|
||||||
linkTelegram: async (
|
linkTelegram: async (
|
||||||
data:
|
data:
|
||||||
| { init_data: string }
|
| { init_data: string }
|
||||||
|
| { id_token: string }
|
||||||
| {
|
| {
|
||||||
id: number;
|
id: number;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ export interface EmailAuthEnabled {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GiftEnabled {
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TelegramWidgetConfig {
|
export interface TelegramWidgetConfig {
|
||||||
bot_username: string;
|
bot_username: string;
|
||||||
size: 'large' | 'medium' | 'small';
|
size: 'large' | 'medium' | 'small';
|
||||||
@@ -251,6 +255,24 @@ export const brandingApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Get gift enabled (public, no auth required)
|
||||||
|
getGiftEnabled: async (): Promise<GiftEnabled> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<GiftEnabled>('/cabinet/branding/gift-enabled');
|
||||||
|
return response.data;
|
||||||
|
} catch {
|
||||||
|
return { enabled: false };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update gift enabled (admin only)
|
||||||
|
updateGiftEnabled: async (enabled: boolean): Promise<GiftEnabled> => {
|
||||||
|
const response = await apiClient.patch<GiftEnabled>('/cabinet/branding/gift-enabled', {
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Get analytics counters (public, no auth required)
|
// Get analytics counters (public, no auth required)
|
||||||
getAnalyticsCounters: async (): Promise<AnalyticsCounters> => {
|
getAnalyticsCounters: async (): Promise<AnalyticsCounters> => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -92,18 +92,22 @@ apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) =>
|
|||||||
if (!isAuthEndpoint(config.url)) {
|
if (!isAuthEndpoint(config.url)) {
|
||||||
let token = tokenStorage.getAccessToken();
|
let token = tokenStorage.getAccessToken();
|
||||||
|
|
||||||
// Проверяем срок действия токена перед запросом
|
|
||||||
if (token && isTokenExpired(token)) {
|
if (token && isTokenExpired(token)) {
|
||||||
// Используем централизованный менеджер для refresh
|
// Access token expired — try refresh
|
||||||
const newToken = await tokenRefreshManager.refreshAccessToken();
|
const newToken = await tokenRefreshManager.refreshAccessToken();
|
||||||
if (newToken) {
|
if (newToken) {
|
||||||
token = newToken;
|
token = newToken;
|
||||||
} else {
|
} else {
|
||||||
// Refresh не удался - редирект на логин
|
|
||||||
tokenStorage.clearTokens();
|
tokenStorage.clearTokens();
|
||||||
safeRedirectToLogin();
|
safeRedirectToLogin();
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
} else if (!token && tokenStorage.getRefreshToken()) {
|
||||||
|
// No access token (e.g. tab reopen) but refresh token exists — restore session
|
||||||
|
const newToken = await tokenRefreshManager.refreshAccessToken();
|
||||||
|
if (newToken) {
|
||||||
|
token = newToken;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token && config.headers) {
|
if (token && config.headers) {
|
||||||
|
|||||||
111
src/api/gift.ts
Normal file
111
src/api/gift.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
|
||||||
|
export interface GiftTariffPeriod {
|
||||||
|
days: number;
|
||||||
|
price_kopeks: number;
|
||||||
|
price_label: string;
|
||||||
|
original_price_kopeks: number | null;
|
||||||
|
discount_percent: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiftTariff {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
traffic_limit_gb: number;
|
||||||
|
device_limit: number;
|
||||||
|
periods: GiftTariffPeriod[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiftPaymentMethodSubOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiftPaymentMethod {
|
||||||
|
method_id: string;
|
||||||
|
display_name: string;
|
||||||
|
description: string | null;
|
||||||
|
icon_url: string | null;
|
||||||
|
min_amount_kopeks: number | null;
|
||||||
|
max_amount_kopeks: number | null;
|
||||||
|
sub_options: GiftPaymentMethodSubOption[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiftConfig {
|
||||||
|
is_enabled: boolean;
|
||||||
|
tariffs: GiftTariff[];
|
||||||
|
payment_methods: GiftPaymentMethod[];
|
||||||
|
balance_kopeks: number;
|
||||||
|
currency_symbol: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiftPurchaseRequest {
|
||||||
|
tariff_id: number;
|
||||||
|
period_days: number;
|
||||||
|
recipient_type: 'email' | 'telegram';
|
||||||
|
recipient_value: string;
|
||||||
|
gift_message?: string;
|
||||||
|
payment_mode: 'balance' | 'gateway';
|
||||||
|
payment_method?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiftPurchaseResponse {
|
||||||
|
status: 'ok' | 'created' | 'paid';
|
||||||
|
purchase_token: string;
|
||||||
|
payment_url: string | null;
|
||||||
|
warning: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GiftPurchaseStatusValue =
|
||||||
|
| 'pending'
|
||||||
|
| 'paid'
|
||||||
|
| 'delivered'
|
||||||
|
| 'pending_activation'
|
||||||
|
| 'failed'
|
||||||
|
| 'expired';
|
||||||
|
|
||||||
|
export interface GiftPurchaseStatus {
|
||||||
|
status: GiftPurchaseStatusValue;
|
||||||
|
is_gift: boolean;
|
||||||
|
recipient_contact_value: string | null;
|
||||||
|
gift_message: string | null;
|
||||||
|
tariff_name: string | null;
|
||||||
|
period_days: number | null;
|
||||||
|
warning: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingGift {
|
||||||
|
token: string;
|
||||||
|
tariff_name: string | null;
|
||||||
|
period_days: number;
|
||||||
|
gift_message: string | null;
|
||||||
|
sender_display: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API
|
||||||
|
|
||||||
|
export const giftApi = {
|
||||||
|
getConfig: async (): Promise<GiftConfig> => {
|
||||||
|
const { data } = await apiClient.get<GiftConfig>('/cabinet/gift/config');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createPurchase: async (request: GiftPurchaseRequest): Promise<GiftPurchaseResponse> => {
|
||||||
|
const { data } = await apiClient.post<GiftPurchaseResponse>('/cabinet/gift/purchase', request);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getPurchaseStatus: async (token: string): Promise<GiftPurchaseStatus> => {
|
||||||
|
const { data } = await apiClient.get<GiftPurchaseStatus>(`/cabinet/gift/purchase/${token}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getPendingGifts: async (): Promise<PendingGift[]> => {
|
||||||
|
const { data } = await apiClient.get<PendingGift[]>('/cabinet/gift/pending');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
92
src/api/menuLayout.ts
Normal file
92
src/api/menuLayout.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
export type OpenIn = 'external' | 'webapp';
|
||||||
|
|
||||||
|
export interface MenuButtonConfig {
|
||||||
|
id: string;
|
||||||
|
type: 'builtin' | 'custom';
|
||||||
|
style: 'primary' | 'success' | 'danger' | 'default';
|
||||||
|
icon_custom_emoji_id: string;
|
||||||
|
enabled: boolean;
|
||||||
|
labels: Record<string, string>;
|
||||||
|
url: string | null;
|
||||||
|
open_in: OpenIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuRowConfig {
|
||||||
|
id: string;
|
||||||
|
max_per_row: number;
|
||||||
|
buttons: MenuButtonConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuConfig {
|
||||||
|
rows: MenuRowConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BOT_LOCALES = ['ru', 'en', 'ua', 'zh', 'fa'] as const;
|
||||||
|
export type BotLocale = (typeof BOT_LOCALES)[number];
|
||||||
|
|
||||||
|
export const BUILTIN_SECTIONS = [
|
||||||
|
'home',
|
||||||
|
'subscription',
|
||||||
|
'balance',
|
||||||
|
'referral',
|
||||||
|
'support',
|
||||||
|
'info',
|
||||||
|
'admin',
|
||||||
|
'language',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type BuiltinSection = (typeof BUILTIN_SECTIONS)[number];
|
||||||
|
|
||||||
|
export const STYLE_OPTIONS = [
|
||||||
|
{ value: 'default' as const, colorClass: 'bg-dark-500' },
|
||||||
|
{ value: 'primary' as const, colorClass: 'bg-blue-500' },
|
||||||
|
{ value: 'success' as const, colorClass: 'bg-success-500' },
|
||||||
|
{ value: 'danger' as const, colorClass: 'bg-red-500' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: MenuConfig = { rows: [] };
|
||||||
|
|
||||||
|
const DEFAULT_BUTTON: Omit<MenuButtonConfig, 'id' | 'type'> = {
|
||||||
|
style: 'primary',
|
||||||
|
icon_custom_emoji_id: '',
|
||||||
|
enabled: true,
|
||||||
|
labels: {},
|
||||||
|
url: null,
|
||||||
|
open_in: 'external',
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeConfig(data: MenuConfig): MenuConfig {
|
||||||
|
if (!data || typeof data !== 'object') {
|
||||||
|
return DEFAULT_CONFIG;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
rows: (data.rows || []).map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
max_per_row: row.max_per_row ?? 2,
|
||||||
|
buttons: (row.buttons || []).map((btn) => ({
|
||||||
|
...DEFAULT_BUTTON,
|
||||||
|
...btn,
|
||||||
|
labels: { ...(btn.labels || {}) },
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const menuLayoutApi = {
|
||||||
|
getConfig: async (): Promise<MenuConfig> => {
|
||||||
|
const response = await apiClient.get<MenuConfig>('/cabinet/admin/menu-layout');
|
||||||
|
return normalizeConfig(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateConfig: async (config: MenuConfig): Promise<MenuConfig> => {
|
||||||
|
const response = await apiClient.put<MenuConfig>('/cabinet/admin/menu-layout', config);
|
||||||
|
return normalizeConfig(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
resetConfig: async (): Promise<MenuConfig> => {
|
||||||
|
const response = await apiClient.post<MenuConfig>('/cabinet/admin/menu-layout/reset');
|
||||||
|
return normalizeConfig(response.data);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -35,6 +35,11 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
|||||||
queryFn: brandingApi.getEmailAuthEnabled,
|
queryFn: brandingApi.getEmailAuthEnabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: giftSettings } = useQuery({
|
||||||
|
queryKey: ['gift-enabled'],
|
||||||
|
queryFn: brandingApi.getGiftEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const updateBrandingMutation = useMutation({
|
const updateBrandingMutation = useMutation({
|
||||||
mutationFn: brandingApi.updateName,
|
mutationFn: brandingApi.updateName,
|
||||||
@@ -76,6 +81,13 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateGiftMutation = useMutation({
|
||||||
|
mutationFn: (enabled: boolean) => brandingApi.updateGiftEnabled(enabled),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['gift-enabled'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
@@ -225,6 +237,18 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
|||||||
disabled={updateEmailAuthMutation.isPending}
|
disabled={updateEmailAuthMutation.isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-4">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-dark-100">{t('admin.settings.giftEnabled')}</span>
|
||||||
|
<p className="text-sm text-dark-400">{t('admin.settings.giftEnabledDesc')}</p>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
checked={giftSettings?.enabled ?? false}
|
||||||
|
onChange={() => updateGiftMutation.mutate(!(giftSettings?.enabled ?? false))}
|
||||||
|
disabled={updateGiftMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
891
src/components/admin/MenuEditorTab.tsx
Normal file
891
src/components/admin/MenuEditorTab.tsx
Normal file
@@ -0,0 +1,891 @@
|
|||||||
|
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
closestCenter,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import {
|
||||||
|
menuLayoutApi,
|
||||||
|
type MenuConfig,
|
||||||
|
type MenuRowConfig,
|
||||||
|
type MenuButtonConfig,
|
||||||
|
BUILTIN_SECTIONS,
|
||||||
|
BOT_LOCALES,
|
||||||
|
STYLE_OPTIONS,
|
||||||
|
} from '../../api/menuLayout';
|
||||||
|
import { Toggle } from './Toggle';
|
||||||
|
import { useNotify } from '../../platform/hooks/useNotify';
|
||||||
|
|
||||||
|
// ============ Icons ============
|
||||||
|
|
||||||
|
const GripIcon = () => (
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TrashIcon = () => (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PlusIcon = () => (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
|
||||||
|
<svg
|
||||||
|
className={`h-3.5 w-3.5 transition-transform ${expanded ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const LinkIcon = () => (
|
||||||
|
<svg
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m9.86-2.54a4.5 4.5 0 00-1.242-7.244l-4.5-4.5a4.5 4.5 0 00-6.364 6.364L4.03 8.591"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ArrowUpIcon = () => (
|
||||||
|
<svg
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ArrowDownIcon = () => (
|
||||||
|
<svg
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============ Helpers ============
|
||||||
|
|
||||||
|
function generateId(): string {
|
||||||
|
return `custom_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateRowId(): string {
|
||||||
|
return `row_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configsEqual(a: MenuConfig, b: MenuConfig): boolean {
|
||||||
|
return JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: MenuConfig = { rows: [] };
|
||||||
|
|
||||||
|
// ============ MaxPerRowSelector ============
|
||||||
|
|
||||||
|
interface MaxPerRowSelectorProps {
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MaxPerRowSelector({ value, onChange }: MaxPerRowSelectorProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[1, 2, 3].map((n) => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
onClick={() => onChange(n)}
|
||||||
|
className={`flex h-7 w-7 items-center justify-center rounded-lg text-xs font-semibold transition-all ${
|
||||||
|
value === n
|
||||||
|
? 'bg-accent-500 text-white'
|
||||||
|
: 'bg-dark-700/50 text-dark-400 hover:bg-dark-600 hover:text-dark-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ ButtonChip ============
|
||||||
|
|
||||||
|
interface ButtonChipProps {
|
||||||
|
button: MenuButtonConfig;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggleExpand: () => void;
|
||||||
|
onUpdate: (updates: Partial<MenuButtonConfig>) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
onMoveUp: (() => void) | null;
|
||||||
|
onMoveDown: (() => void) | null;
|
||||||
|
isBuiltin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ButtonChip({
|
||||||
|
button,
|
||||||
|
isExpanded,
|
||||||
|
onToggleExpand,
|
||||||
|
onUpdate,
|
||||||
|
onRemove,
|
||||||
|
onMoveUp,
|
||||||
|
onMoveDown,
|
||||||
|
isBuiltin,
|
||||||
|
}: ButtonChipProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const displayName =
|
||||||
|
button.labels.ru ||
|
||||||
|
button.labels.en ||
|
||||||
|
(isBuiltin ? t(`admin.buttons.sections.${button.id}`) : button.id);
|
||||||
|
|
||||||
|
const styleOption = STYLE_OPTIONS.find((s) => s.value === button.style);
|
||||||
|
const colorDotClass = styleOption?.colorClass || 'bg-dark-500';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`overflow-hidden rounded-xl border transition-colors ${
|
||||||
|
button.enabled
|
||||||
|
? 'border-dark-700/50 bg-dark-800/50'
|
||||||
|
: 'border-dark-700/30 bg-dark-800/30 opacity-60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Collapsed header */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2.5">
|
||||||
|
<div className="flex shrink-0 flex-col">
|
||||||
|
<button
|
||||||
|
onClick={onMoveUp ?? undefined}
|
||||||
|
disabled={!onMoveUp}
|
||||||
|
aria-label={t('admin.menuEditor.moveUp')}
|
||||||
|
className={`rounded-lg p-1.5 transition-colors ${
|
||||||
|
onMoveUp
|
||||||
|
? 'text-dark-400 hover:bg-dark-700/50 hover:text-dark-300'
|
||||||
|
: 'cursor-default text-dark-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ArrowUpIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onMoveDown ?? undefined}
|
||||||
|
disabled={!onMoveDown}
|
||||||
|
aria-label={t('admin.menuEditor.moveDown')}
|
||||||
|
className={`rounded-lg p-1.5 transition-colors ${
|
||||||
|
onMoveDown
|
||||||
|
? 'text-dark-400 hover:bg-dark-700/50 hover:text-dark-300'
|
||||||
|
: 'cursor-default text-dark-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ArrowDownIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${colorDotClass}`} />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm font-medium text-dark-100">
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
{!isBuiltin && (
|
||||||
|
<span className="text-dark-500" title="URL">
|
||||||
|
<LinkIcon />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Toggle checked={button.enabled} onChange={() => onUpdate({ enabled: !button.enabled })} />
|
||||||
|
<button
|
||||||
|
onClick={onToggleExpand}
|
||||||
|
className="rounded-lg p-1 text-dark-400 transition-colors hover:bg-dark-700/50 hover:text-dark-300"
|
||||||
|
>
|
||||||
|
<ChevronIcon expanded={isExpanded} />
|
||||||
|
</button>
|
||||||
|
{!isBuiltin && (
|
||||||
|
<button
|
||||||
|
onClick={onRemove}
|
||||||
|
className="rounded-lg p-1 text-dark-500 transition-colors hover:bg-red-500/10 hover:text-red-400"
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded body */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="space-y-3 border-t border-dark-700/30 px-3 py-3">
|
||||||
|
{/* Color selector */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-dark-300">
|
||||||
|
{t('admin.buttons.color')}
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{STYLE_OPTIONS.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => onUpdate({ style: opt.value })}
|
||||||
|
className={`flex h-7 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-medium transition-all ${
|
||||||
|
button.style === opt.value
|
||||||
|
? 'border-accent-500 bg-accent-500/10 text-accent-400'
|
||||||
|
: 'border-dark-600 bg-dark-700/50 text-dark-300 hover:border-dark-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${opt.colorClass}`} />
|
||||||
|
{t(`admin.buttons.styles.${opt.value}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Emoji ID */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-dark-300">
|
||||||
|
{t('admin.buttons.emojiId')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={button.icon_custom_emoji_id}
|
||||||
|
onChange={(e) => onUpdate({ icon_custom_emoji_id: e.target.value })}
|
||||||
|
placeholder={t('admin.buttons.emojiPlaceholder')}
|
||||||
|
className="w-full rounded-lg border border-dark-600 bg-dark-700/50 px-3 py-2 text-sm text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* URL input + open mode (custom buttons only) */}
|
||||||
|
{!isBuiltin && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-dark-300">URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={button.url || ''}
|
||||||
|
onChange={(e) => onUpdate({ url: e.target.value || null })}
|
||||||
|
placeholder="https://..."
|
||||||
|
className="w-full rounded-lg border border-dark-600 bg-dark-700/50 px-3 py-2 text-sm text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-dark-300">
|
||||||
|
{t('admin.menuEditor.openIn')}
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{(['external', 'webapp'] as const).map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
onClick={() => onUpdate({ open_in: mode })}
|
||||||
|
className={`flex h-7 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-medium transition-all ${
|
||||||
|
button.open_in === mode
|
||||||
|
? 'border-accent-500 bg-accent-500/10 text-accent-400'
|
||||||
|
: 'border-dark-600 bg-dark-700/50 text-dark-300 hover:border-dark-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`admin.menuEditor.openMode.${mode}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Localized labels */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-dark-300">
|
||||||
|
{t('admin.buttons.customLabels')}
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{BOT_LOCALES.map((locale) => (
|
||||||
|
<div key={locale} className="flex items-center gap-2">
|
||||||
|
<span className="w-7 shrink-0 text-center text-[10px] font-semibold uppercase text-dark-500">
|
||||||
|
{locale}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={button.labels[locale] || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdate({
|
||||||
|
labels: { ...button.labels, [locale]: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder={t('admin.menuEditor.buttonTextPlaceholder')}
|
||||||
|
maxLength={100}
|
||||||
|
className="w-full rounded-lg border border-dark-600 bg-dark-700/50 px-3 py-1.5 text-sm text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="text-[10px] text-dark-500">{t('admin.menuEditor.customLabelsHint')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ SortableRow ============
|
||||||
|
|
||||||
|
interface SortableRowProps {
|
||||||
|
row: MenuRowConfig;
|
||||||
|
rowIndex: number;
|
||||||
|
expandedButtons: Set<string>;
|
||||||
|
usedBuiltinIds: Set<string>;
|
||||||
|
onToggleExpand: (buttonId: string) => void;
|
||||||
|
onUpdateRow: (rowId: string, updates: Partial<MenuRowConfig>) => void;
|
||||||
|
onRemoveRow: (rowId: string) => void;
|
||||||
|
onUpdateButton: (rowId: string, buttonId: string, updates: Partial<MenuButtonConfig>) => void;
|
||||||
|
onRemoveButton: (rowId: string, buttonId: string) => void;
|
||||||
|
onAddBuiltin: (rowId: string, sectionId: string) => void;
|
||||||
|
onAddCustom: (rowId: string) => void;
|
||||||
|
onReorderButton: (rowId: string, buttonIndex: number, direction: 'up' | 'down') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableRow({
|
||||||
|
row,
|
||||||
|
rowIndex,
|
||||||
|
expandedButtons,
|
||||||
|
usedBuiltinIds,
|
||||||
|
onToggleExpand,
|
||||||
|
onUpdateRow,
|
||||||
|
onRemoveRow,
|
||||||
|
onUpdateButton,
|
||||||
|
onRemoveButton,
|
||||||
|
onAddBuiltin,
|
||||||
|
onAddCustom,
|
||||||
|
onReorderButton,
|
||||||
|
}: SortableRowProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: row.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
zIndex: isDragging ? 50 : undefined,
|
||||||
|
position: isDragging ? 'relative' : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const allBuiltin = row.buttons.every((b) => b.type === 'builtin');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`overflow-hidden rounded-2xl border bg-dark-800/50 transition-all ${
|
||||||
|
isDragging ? 'border-accent-500/50 shadow-xl shadow-accent-500/20' : 'border-dark-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Row header */}
|
||||||
|
<div className="flex items-center gap-3 border-b border-dark-700/30 px-4 py-3">
|
||||||
|
<button
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="flex-shrink-0 cursor-grab touch-none rounded-lg p-1.5 text-dark-500 hover:bg-dark-700/50 hover:text-dark-300 active:cursor-grabbing"
|
||||||
|
title={t('admin.menuEditor.dragToReorder')}
|
||||||
|
>
|
||||||
|
<GripIcon />
|
||||||
|
</button>
|
||||||
|
<span className="text-sm font-semibold text-dark-200">
|
||||||
|
{t('admin.menuEditor.row')} {rowIndex + 1}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<MaxPerRowSelector
|
||||||
|
value={row.max_per_row}
|
||||||
|
onChange={(value) => onUpdateRow(row.id, { max_per_row: value })}
|
||||||
|
/>
|
||||||
|
{!allBuiltin && (
|
||||||
|
<button
|
||||||
|
onClick={() => onRemoveRow(row.id)}
|
||||||
|
className="rounded-lg p-1.5 text-dark-500 transition-colors hover:bg-red-500/10 hover:text-red-400"
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row body */}
|
||||||
|
<div className="space-y-2 p-3">
|
||||||
|
{row.buttons.map((button, btnIndex) => (
|
||||||
|
<ButtonChip
|
||||||
|
key={button.id}
|
||||||
|
button={button}
|
||||||
|
isExpanded={expandedButtons.has(button.id)}
|
||||||
|
onToggleExpand={() => onToggleExpand(button.id)}
|
||||||
|
onUpdate={(updates) => onUpdateButton(row.id, button.id, updates)}
|
||||||
|
onRemove={() => onRemoveButton(row.id, button.id)}
|
||||||
|
onMoveUp={btnIndex > 0 ? () => onReorderButton(row.id, btnIndex, 'up') : null}
|
||||||
|
onMoveDown={
|
||||||
|
btnIndex < row.buttons.length - 1
|
||||||
|
? () => onReorderButton(row.id, btnIndex, 'down')
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
isBuiltin={button.type === 'builtin'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Inline add button panel */}
|
||||||
|
<InlineAddPanel
|
||||||
|
rowId={row.id}
|
||||||
|
usedBuiltinIds={usedBuiltinIds}
|
||||||
|
onAddBuiltin={onAddBuiltin}
|
||||||
|
onAddCustom={onAddCustom}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ InlineAddPanel ============
|
||||||
|
|
||||||
|
interface InlineAddPanelProps {
|
||||||
|
rowId: string;
|
||||||
|
usedBuiltinIds: Set<string>;
|
||||||
|
onAddBuiltin: (rowId: string, sectionId: string) => void;
|
||||||
|
onAddCustom: (rowId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InlineAddPanel({ rowId, usedBuiltinIds, onAddBuiltin, onAddCustom }: InlineAddPanelProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const availableBuiltins = BUILTIN_SECTIONS.filter((id) => !usedBuiltinIds.has(id));
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-dark-700/50 py-2.5 text-sm text-dark-500 transition-colors hover:border-dark-600 hover:text-dark-400"
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
{t('admin.menuEditor.addButton')}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1 rounded-xl border border-dark-700/50 bg-dark-900/30 p-2">
|
||||||
|
{availableBuiltins.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="px-2 pb-0.5 text-xs font-medium text-dark-500">
|
||||||
|
{t('admin.menuEditor.builtinButtons')}
|
||||||
|
</p>
|
||||||
|
{availableBuiltins.map((id) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => {
|
||||||
|
onAddBuiltin(rowId, id);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-dark-200 transition-colors hover:bg-dark-700/50"
|
||||||
|
>
|
||||||
|
{t(`admin.buttons.sections.${id}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div className="my-1 border-t border-dark-700/30" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onAddCustom(rowId);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-dark-200 transition-colors hover:bg-dark-700/50"
|
||||||
|
>
|
||||||
|
<LinkIcon />
|
||||||
|
{t('admin.menuEditor.addUrlButton')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="flex w-full items-center justify-center rounded-lg py-1.5 text-xs text-dark-500 transition-colors hover:text-dark-400"
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ MenuEditorTab ============
|
||||||
|
|
||||||
|
export function MenuEditorTab() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const notify = useNotify();
|
||||||
|
|
||||||
|
// Fetch config
|
||||||
|
const {
|
||||||
|
data: serverConfig,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['menu-layout'],
|
||||||
|
queryFn: menuLayoutApi.getConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draft state
|
||||||
|
const [draftConfig, setDraftConfig] = useState<MenuConfig>(DEFAULT_CONFIG);
|
||||||
|
const [expandedButtons, setExpandedButtons] = useState<Set<string>>(new Set());
|
||||||
|
const savedConfigRef = useRef<MenuConfig>(DEFAULT_CONFIG);
|
||||||
|
const draftConfigRef = useRef(draftConfig);
|
||||||
|
draftConfigRef.current = draftConfig;
|
||||||
|
|
||||||
|
// Sync server data to draft (same pattern as ButtonsTab)
|
||||||
|
useEffect(() => {
|
||||||
|
if (serverConfig) {
|
||||||
|
if (
|
||||||
|
configsEqual(savedConfigRef.current, draftConfigRef.current) ||
|
||||||
|
configsEqual(savedConfigRef.current, DEFAULT_CONFIG)
|
||||||
|
) {
|
||||||
|
setDraftConfig(serverConfig);
|
||||||
|
savedConfigRef.current = serverConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [serverConfig]);
|
||||||
|
|
||||||
|
const hasUnsavedChanges = !configsEqual(draftConfig, savedConfigRef.current);
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: menuLayoutApi.updateConfig,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
savedConfigRef.current = data;
|
||||||
|
setDraftConfig(data);
|
||||||
|
queryClient.setQueryData(['menu-layout'], data);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
notify.error(t('common.error'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetMutation = useMutation({
|
||||||
|
mutationFn: menuLayoutApi.resetConfig,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
savedConfigRef.current = data;
|
||||||
|
setDraftConfig(data);
|
||||||
|
queryClient.setQueryData(['menu-layout'], data);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
notify.error(t('common.error'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
setDraftConfig((prev) => {
|
||||||
|
const oldIndex = prev.rows.findIndex((r) => r.id === active.id);
|
||||||
|
const newIndex = prev.rows.findIndex((r) => r.id === over.id);
|
||||||
|
if (oldIndex === -1 || newIndex === -1) return prev;
|
||||||
|
return { ...prev, rows: arrayMove(prev.rows, oldIndex, newIndex) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Row CRUD
|
||||||
|
const updateRow = useCallback((rowId: string, updates: Partial<MenuRowConfig>) => {
|
||||||
|
setDraftConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
rows: prev.rows.map((r) => (r.id === rowId ? { ...r, ...updates } : r)),
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeRow = useCallback((rowId: string) => {
|
||||||
|
setDraftConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
rows: prev.rows.filter((r) => r.id !== rowId),
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addRow = useCallback(() => {
|
||||||
|
setDraftConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
rows: [
|
||||||
|
...prev.rows,
|
||||||
|
{
|
||||||
|
id: generateRowId(),
|
||||||
|
max_per_row: 2,
|
||||||
|
buttons: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Button CRUD
|
||||||
|
const updateButton = useCallback(
|
||||||
|
(rowId: string, buttonId: string, updates: Partial<MenuButtonConfig>) => {
|
||||||
|
setDraftConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
rows: prev.rows.map((r) =>
|
||||||
|
r.id === rowId
|
||||||
|
? {
|
||||||
|
...r,
|
||||||
|
buttons: r.buttons.map((b) => (b.id === buttonId ? { ...b, ...updates } : b)),
|
||||||
|
}
|
||||||
|
: r,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeButton = useCallback((rowId: string, buttonId: string) => {
|
||||||
|
setDraftConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
rows: prev.rows.map((r) =>
|
||||||
|
r.id === rowId ? { ...r, buttons: r.buttons.filter((b) => b.id !== buttonId) } : r,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addBuiltinButton = useCallback((rowId: string, sectionId: string) => {
|
||||||
|
const newButton: MenuButtonConfig = {
|
||||||
|
id: sectionId,
|
||||||
|
type: 'builtin',
|
||||||
|
style: 'default',
|
||||||
|
icon_custom_emoji_id: '',
|
||||||
|
enabled: true,
|
||||||
|
labels: {},
|
||||||
|
url: null,
|
||||||
|
open_in: 'external',
|
||||||
|
};
|
||||||
|
setDraftConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
rows: prev.rows.map((r) =>
|
||||||
|
r.id === rowId ? { ...r, buttons: [...r.buttons, newButton] } : r,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addCustomButton = useCallback((rowId: string) => {
|
||||||
|
const newButton: MenuButtonConfig = {
|
||||||
|
id: generateId(),
|
||||||
|
type: 'custom',
|
||||||
|
style: 'default',
|
||||||
|
icon_custom_emoji_id: '',
|
||||||
|
enabled: true,
|
||||||
|
labels: {},
|
||||||
|
url: '',
|
||||||
|
open_in: 'external',
|
||||||
|
};
|
||||||
|
setDraftConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
rows: prev.rows.map((r) =>
|
||||||
|
r.id === rowId ? { ...r, buttons: [...r.buttons, newButton] } : r,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reorderButton = useCallback(
|
||||||
|
(rowId: string, buttonIndex: number, direction: 'up' | 'down') => {
|
||||||
|
setDraftConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
rows: prev.rows.map((r) => {
|
||||||
|
if (r.id !== rowId) return r;
|
||||||
|
const newIndex = direction === 'up' ? buttonIndex - 1 : buttonIndex + 1;
|
||||||
|
if (newIndex < 0 || newIndex >= r.buttons.length) return r;
|
||||||
|
return { ...r, buttons: arrayMove(r.buttons, buttonIndex, newIndex) };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Expand/collapse
|
||||||
|
const toggleExpand = useCallback((buttonId: string) => {
|
||||||
|
setExpandedButtons((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(buttonId)) {
|
||||||
|
next.delete(buttonId);
|
||||||
|
} else {
|
||||||
|
next.add(buttonId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Collect used builtin IDs across all rows
|
||||||
|
const usedBuiltinIds = useMemo(
|
||||||
|
() =>
|
||||||
|
new Set(
|
||||||
|
draftConfig.rows.flatMap((r) =>
|
||||||
|
r.buttons.filter((b) => b.type === 'builtin').map((b) => b.id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
[draftConfig.rows],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
// Validate custom buttons have valid URLs
|
||||||
|
const currentDraft = draftConfigRef.current;
|
||||||
|
for (const row of currentDraft.rows) {
|
||||||
|
for (const btn of row.buttons) {
|
||||||
|
if (btn.type === 'custom') {
|
||||||
|
if (!btn.url || (!btn.url.startsWith('http://') && !btn.url.startsWith('https://'))) {
|
||||||
|
notify.error(t('admin.menuEditor.invalidUrl'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateMutation.mutate(currentDraft);
|
||||||
|
}, [updateMutation, notify, t]);
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
setDraftConfig(savedConfigRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12 text-dark-400">
|
||||||
|
<svg className="mr-2 h-5 w-5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{t('common.loading')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-400">
|
||||||
|
{t('common.error')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Drag hint */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-dark-500">
|
||||||
|
<GripIcon />
|
||||||
|
{t('admin.menuEditor.dragHint')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rows */}
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
|
<SortableContext
|
||||||
|
items={draftConfig.rows.map((r) => r.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{draftConfig.rows.map((row, index) => (
|
||||||
|
<SortableRow
|
||||||
|
key={row.id}
|
||||||
|
row={row}
|
||||||
|
rowIndex={index}
|
||||||
|
expandedButtons={expandedButtons}
|
||||||
|
usedBuiltinIds={usedBuiltinIds}
|
||||||
|
onToggleExpand={toggleExpand}
|
||||||
|
onUpdateRow={updateRow}
|
||||||
|
onRemoveRow={removeRow}
|
||||||
|
onUpdateButton={updateButton}
|
||||||
|
onRemoveButton={removeButton}
|
||||||
|
onAddBuiltin={addBuiltinButton}
|
||||||
|
onAddCustom={addCustomButton}
|
||||||
|
onReorderButton={reorderButton}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
|
{/* Add row */}
|
||||||
|
<button
|
||||||
|
onClick={addRow}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-dark-700/50 py-4 text-sm font-medium text-dark-500 transition-colors hover:border-dark-600 hover:text-dark-400"
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
{t('admin.menuEditor.addRow')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Save / Cancel */}
|
||||||
|
{hasUnsavedChanges && (
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
className="rounded-xl bg-accent-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? t('common.saving') : t('common.save')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
className="rounded-xl bg-dark-700 px-4 py-2 text-sm font-medium text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reset */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(t('admin.menuEditor.resetConfirm'))) {
|
||||||
|
resetMutation.mutate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={resetMutation.isPending}
|
||||||
|
className="rounded-xl bg-dark-700 px-4 py-2 text-sm text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('admin.buttons.resetAll')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ function safeOpenUrl(url: string | undefined | null): void {
|
|||||||
try {
|
try {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
|
if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
|
||||||
window.open(url, '_blank', 'noopener,noreferrer');
|
window.open(url, '_blank', 'noopener');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// invalid URL, do nothing
|
// invalid URL, do nothing
|
||||||
|
|||||||
80
src/components/dashboard/PendingGiftCard.tsx
Normal file
80
src/components/dashboard/PendingGiftCard.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { Link } from 'react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import type { PendingGift } from '../../api/gift';
|
||||||
|
|
||||||
|
interface PendingGiftCardProps {
|
||||||
|
gifts: PendingGift[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PendingGiftCard({ gifts, className }: PendingGiftCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (gifts.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className ?? 'space-y-3'}>
|
||||||
|
{gifts.map((gift) => (
|
||||||
|
<motion.div
|
||||||
|
key={gift.token}
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="relative overflow-hidden rounded-2xl border border-accent-500/30 bg-gradient-to-r from-accent-500/10 via-purple-500/10 to-accent-500/10 p-5"
|
||||||
|
>
|
||||||
|
{/* Subtle glow effect */}
|
||||||
|
<div className="absolute -right-8 -top-8 h-24 w-24 rounded-full bg-accent-500/10 blur-2xl" />
|
||||||
|
|
||||||
|
<div className="relative flex items-start gap-4">
|
||||||
|
{/* Gift icon */}
|
||||||
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-accent-500/20">
|
||||||
|
<svg
|
||||||
|
className="h-6 w-6 text-accent-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="text-sm font-semibold text-dark-50">{t('gift.pending.title')}</h3>
|
||||||
|
<p className="mt-0.5 text-xs text-dark-300">
|
||||||
|
{gift.tariff_name && (
|
||||||
|
<span>
|
||||||
|
{gift.tariff_name} — {gift.period_days} {t('gift.days')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{gift.sender_display && (
|
||||||
|
<span className="ml-1 text-dark-400">
|
||||||
|
{t('gift.pending.from', { sender: gift.sender_display })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{gift.gift_message && (
|
||||||
|
<p className="mt-1.5 line-clamp-2 text-xs italic text-dark-400">
|
||||||
|
“{gift.gift_message}”
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activate button */}
|
||||||
|
<Link
|
||||||
|
to={`/buy/success/${gift.token}?activate=1`}
|
||||||
|
className="shrink-0 rounded-xl bg-accent-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-400"
|
||||||
|
>
|
||||||
|
{t('gift.pending.activate')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
InfoIcon,
|
InfoIcon,
|
||||||
CogIcon,
|
CogIcon,
|
||||||
WheelIcon,
|
WheelIcon,
|
||||||
|
GiftIcon,
|
||||||
MenuIcon,
|
MenuIcon,
|
||||||
CloseIcon,
|
CloseIcon,
|
||||||
SunIcon,
|
SunIcon,
|
||||||
@@ -60,6 +61,7 @@ interface AppHeaderProps {
|
|||||||
referralEnabled?: boolean;
|
referralEnabled?: boolean;
|
||||||
hasContests?: boolean;
|
hasContests?: boolean;
|
||||||
hasPolls?: boolean;
|
hasPolls?: boolean;
|
||||||
|
giftEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppHeader({
|
export function AppHeader({
|
||||||
@@ -75,6 +77,7 @@ export function AppHeader({
|
|||||||
referralEnabled,
|
referralEnabled,
|
||||||
hasContests,
|
hasContests,
|
||||||
hasPolls,
|
hasPolls,
|
||||||
|
giftEnabled,
|
||||||
}: AppHeaderProps) {
|
}: AppHeaderProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -164,6 +167,7 @@ export function AppHeader({
|
|||||||
...(hasContests ? [{ path: '/contests', label: t('nav.contests'), icon: GamepadIcon }] : []),
|
...(hasContests ? [{ path: '/contests', label: t('nav.contests'), icon: GamepadIcon }] : []),
|
||||||
...(hasPolls ? [{ path: '/polls', label: t('nav.polls'), icon: ClipboardIcon }] : []),
|
...(hasPolls ? [{ path: '/polls', label: t('nav.polls'), icon: ClipboardIcon }] : []),
|
||||||
...(wheelEnabled ? [{ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon }] : []),
|
...(wheelEnabled ? [{ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon }] : []),
|
||||||
|
...(giftEnabled ? [{ path: '/gift', label: t('nav.gift'), icon: GiftIcon }] : []),
|
||||||
{ path: '/info', label: t('nav.info'), icon: InfoIcon },
|
{ path: '/info', label: t('nav.info'), icon: InfoIcon },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useLocation, Link } from 'react-router';
|
import { useLocation, Link } from 'react-router';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -20,7 +20,7 @@ import CampaignBonusNotifier from '@/components/CampaignBonusNotifier';
|
|||||||
import SuccessNotificationModal from '@/components/SuccessNotificationModal';
|
import SuccessNotificationModal from '@/components/SuccessNotificationModal';
|
||||||
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
||||||
import TicketNotificationBell from '@/components/TicketNotificationBell';
|
import TicketNotificationBell from '@/components/TicketNotificationBell';
|
||||||
import { SubscriptionIcon } from '@/components/icons';
|
import { SubscriptionIcon, GiftIcon } from '@/components/icons';
|
||||||
|
|
||||||
import { MobileBottomNav } from './MobileBottomNav';
|
import { MobileBottomNav } from './MobileBottomNav';
|
||||||
import { AppHeader } from './AppHeader';
|
import { AppHeader } from './AppHeader';
|
||||||
@@ -203,7 +203,7 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
|
|
||||||
// Extracted hooks
|
// Extracted hooks
|
||||||
const { appName, logoLetter, hasCustomLogo, logoUrl } = useBranding();
|
const { appName, logoLetter, hasCustomLogo, logoUrl } = useBranding();
|
||||||
const { referralEnabled, wheelEnabled, hasContests, hasPolls } = useFeatureFlags();
|
const { referralEnabled, wheelEnabled, hasContests, hasPolls, giftEnabled } = useFeatureFlags();
|
||||||
useScrollRestoration();
|
useScrollRestoration();
|
||||||
|
|
||||||
// Theme toggle visibility
|
// Theme toggle visibility
|
||||||
@@ -269,6 +269,31 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
haptic.impact('light');
|
haptic.impact('light');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Desktop nav scroll fade indicators
|
||||||
|
const navRef = useRef<HTMLElement>(null);
|
||||||
|
const [navCanScrollLeft, setNavCanScrollLeft] = useState(false);
|
||||||
|
const [navCanScrollRight, setNavCanScrollRight] = useState(false);
|
||||||
|
|
||||||
|
const updateNavScroll = useCallback(() => {
|
||||||
|
const el = navRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
setNavCanScrollLeft(el.scrollLeft > 2);
|
||||||
|
setNavCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 2);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = navRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
updateNavScroll();
|
||||||
|
el.addEventListener('scroll', updateNavScroll, { passive: true });
|
||||||
|
const ro = new ResizeObserver(updateNavScroll);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('scroll', updateNavScroll);
|
||||||
|
ro.disconnect();
|
||||||
|
};
|
||||||
|
}, [updateNavScroll]);
|
||||||
|
|
||||||
// Calculate header height based on fullscreen mode (only on mobile Telegram)
|
// Calculate header height based on fullscreen mode (only on mobile Telegram)
|
||||||
// On iOS: contentSafeAreaInset.top includes status bar + dynamic island + Telegram header
|
// On iOS: contentSafeAreaInset.top includes status bar + dynamic island + Telegram header
|
||||||
// On Android: safeAreaInset.top only includes status bar, need to add Telegram header height (~48px)
|
// On Android: safeAreaInset.top only includes status bar, need to add Telegram header height (~48px)
|
||||||
@@ -317,58 +342,89 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Center Navigation */}
|
{/* Center Navigation */}
|
||||||
<nav className="flex items-center justify-center gap-1">
|
<div className="relative min-w-0">
|
||||||
{desktopNavItems.map((item) => (
|
{/* Left fade */}
|
||||||
<Link
|
<div
|
||||||
key={item.path}
|
className={cn(
|
||||||
to={item.path}
|
'pointer-events-none absolute bottom-0 left-0 top-0 z-10 w-6 bg-gradient-to-r from-dark-950/95 to-transparent transition-opacity duration-200',
|
||||||
onClick={handleNavClick}
|
navCanScrollLeft ? 'opacity-100' : 'opacity-0',
|
||||||
className={cn(
|
)}
|
||||||
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
/>
|
||||||
isActive(item.path)
|
{/* Right fade */}
|
||||||
? 'bg-dark-800 text-dark-50'
|
<div
|
||||||
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
|
className={cn(
|
||||||
)}
|
'pointer-events-none absolute bottom-0 right-0 top-0 z-10 w-6 bg-gradient-to-l from-dark-950/95 to-transparent transition-opacity duration-200',
|
||||||
>
|
navCanScrollRight ? 'opacity-100' : 'opacity-0',
|
||||||
<item.icon className="h-4 w-4" />
|
)}
|
||||||
<span>{item.label}</span>
|
/>
|
||||||
</Link>
|
<nav ref={navRef} className="scrollbar-hide flex items-center gap-1 overflow-x-auto">
|
||||||
))}
|
{desktopNavItems.map((item) => (
|
||||||
{referralEnabled && (
|
|
||||||
<Link
|
|
||||||
to="/referral"
|
|
||||||
onClick={handleNavClick}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
|
||||||
isActive('/referral')
|
|
||||||
? 'bg-dark-800 text-dark-50'
|
|
||||||
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<UsersIcon className="h-4 w-4" />
|
|
||||||
<span>{t('nav.referral')}</span>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
|
||||||
<>
|
|
||||||
{/* Separator before admin */}
|
|
||||||
<div className="mx-2 h-5 w-px bg-dark-700" />
|
|
||||||
<Link
|
<Link
|
||||||
to="/admin"
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
onClick={handleNavClick}
|
onClick={handleNavClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
'flex shrink-0 items-center gap-2 whitespace-nowrap rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||||
location.pathname.startsWith('/admin')
|
isActive(item.path)
|
||||||
? 'bg-warning-500/10 text-warning-400'
|
? 'bg-dark-800 text-dark-50'
|
||||||
: 'text-warning-500/70 hover:bg-warning-500/10 hover:text-warning-400',
|
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ShieldIcon className="h-4 w-4" />
|
<item.icon className="h-4 w-4" />
|
||||||
<span>{t('admin.nav.title')}</span>
|
<span>{item.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
))}
|
||||||
)}
|
{referralEnabled && (
|
||||||
</nav>
|
<Link
|
||||||
|
to="/referral"
|
||||||
|
onClick={handleNavClick}
|
||||||
|
className={cn(
|
||||||
|
'flex shrink-0 items-center gap-2 whitespace-nowrap rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
isActive('/referral')
|
||||||
|
? 'bg-dark-800 text-dark-50'
|
||||||
|
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<UsersIcon className="h-4 w-4" />
|
||||||
|
<span>{t('nav.referral')}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{giftEnabled && (
|
||||||
|
<Link
|
||||||
|
to="/gift"
|
||||||
|
onClick={handleNavClick}
|
||||||
|
className={cn(
|
||||||
|
'flex shrink-0 items-center gap-2 whitespace-nowrap rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
isActive('/gift')
|
||||||
|
? 'bg-dark-800 text-dark-50'
|
||||||
|
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<GiftIcon className="h-4 w-4" />
|
||||||
|
<span>{t('nav.gift')}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
{/* Separator before admin */}
|
||||||
|
<div className="mx-2 h-5 w-px shrink-0 bg-dark-700" />
|
||||||
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
onClick={handleNavClick}
|
||||||
|
className={cn(
|
||||||
|
'flex shrink-0 items-center gap-2 whitespace-nowrap rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
location.pathname.startsWith('/admin')
|
||||||
|
? 'bg-warning-500/10 text-warning-400'
|
||||||
|
: 'text-warning-500/70 hover:bg-warning-500/10 hover:text-warning-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ShieldIcon className="h-4 w-4" />
|
||||||
|
<span>{t('admin.nav.title')}</span>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Right side actions */}
|
{/* Right side actions */}
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
@@ -415,6 +471,7 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
referralEnabled={referralEnabled}
|
referralEnabled={referralEnabled}
|
||||||
hasContests={hasContests}
|
hasContests={hasContests}
|
||||||
hasPolls={hasPolls}
|
hasPolls={hasPolls}
|
||||||
|
giftEnabled={giftEnabled}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Desktop spacer */}
|
{/* Desktop spacer */}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export {
|
|||||||
InfoIcon,
|
InfoIcon,
|
||||||
CogIcon,
|
CogIcon,
|
||||||
WheelIcon,
|
WheelIcon,
|
||||||
|
GiftIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useAuthStore } from '@/store/auth';
|
import { useAuthStore } from '@/store/auth';
|
||||||
|
import { brandingApi } from '@/api/branding';
|
||||||
import { referralApi } from '@/api/referral';
|
import { referralApi } from '@/api/referral';
|
||||||
import { wheelApi } from '@/api/wheel';
|
import { wheelApi } from '@/api/wheel';
|
||||||
import { contestsApi } from '@/api/contests';
|
import { contestsApi } from '@/api/contests';
|
||||||
@@ -40,10 +41,19 @@ export function useFeatureFlags() {
|
|||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: giftConfig } = useQuery({
|
||||||
|
queryKey: ['gift-enabled'],
|
||||||
|
queryFn: brandingApi.getGiftEnabled,
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
staleTime: 60000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
referralEnabled: referralTerms?.is_enabled,
|
referralEnabled: referralTerms?.is_enabled,
|
||||||
wheelEnabled: wheelConfig?.is_enabled,
|
wheelEnabled: wheelConfig?.is_enabled,
|
||||||
hasContests: (contestsCount?.count ?? 0) > 0,
|
hasContests: (contestsCount?.count ?? 0) > 0,
|
||||||
hasPolls: (pollsCount?.count ?? 0) > 0,
|
hasPolls: (pollsCount?.count ?? 0) > 0,
|
||||||
|
giftEnabled: giftConfig?.enabled,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,8 @@
|
|||||||
"polls": "Polls",
|
"polls": "Polls",
|
||||||
"info": "Info",
|
"info": "Info",
|
||||||
"wheel": "Fortune Wheel",
|
"wheel": "Fortune Wheel",
|
||||||
"menu": "Menu"
|
"menu": "Menu",
|
||||||
|
"gift": "Gift"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"ticketNotifications": "Ticket Notifications",
|
"ticketNotifications": "Ticket Notifications",
|
||||||
@@ -1695,6 +1696,8 @@
|
|||||||
"darkTheme": "Dark theme",
|
"darkTheme": "Dark theme",
|
||||||
"emailAuth": "Email auth",
|
"emailAuth": "Email auth",
|
||||||
"emailAuthDesc": "Allow login via email",
|
"emailAuthDesc": "Allow login via email",
|
||||||
|
"giftEnabled": "Gift subscription",
|
||||||
|
"giftEnabledDesc": "Allow users to gift a subscription to another user",
|
||||||
"favoritesEmpty": "No favorites yet",
|
"favoritesEmpty": "No favorites yet",
|
||||||
"favoritesHint": "Star settings to add them here",
|
"favoritesHint": "Star settings to add them here",
|
||||||
"interfaceOptions": "Interface options",
|
"interfaceOptions": "Interface options",
|
||||||
@@ -1745,7 +1748,8 @@
|
|||||||
"referral": "Referrals",
|
"referral": "Referrals",
|
||||||
"support": "Support",
|
"support": "Support",
|
||||||
"info": "Info",
|
"info": "Info",
|
||||||
"admin": "Admin Panel"
|
"admin": "Admin Panel",
|
||||||
|
"language": "Language"
|
||||||
},
|
},
|
||||||
"descriptions": {
|
"descriptions": {
|
||||||
"home": "Personal cabinet button",
|
"home": "Personal cabinet button",
|
||||||
@@ -1754,7 +1758,8 @@
|
|||||||
"referral": "Referral program",
|
"referral": "Referral program",
|
||||||
"support": "Support tickets",
|
"support": "Support tickets",
|
||||||
"info": "Information section",
|
"info": "Information section",
|
||||||
"admin": "Web admin panel"
|
"admin": "Web admin panel",
|
||||||
|
"language": "Language selection"
|
||||||
},
|
},
|
||||||
"styles": {
|
"styles": {
|
||||||
"default": "No color",
|
"default": "No color",
|
||||||
@@ -1763,6 +1768,26 @@
|
|||||||
"danger": "Red"
|
"danger": "Red"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"menuEditor": {
|
||||||
|
"dragHint": "Drag rows to reorder them",
|
||||||
|
"dragToReorder": "Drag to reorder",
|
||||||
|
"row": "Row",
|
||||||
|
"addRow": "Add row",
|
||||||
|
"addButton": "Add button",
|
||||||
|
"addUrlButton": "URL button (link)",
|
||||||
|
"builtinButtons": "Built-in buttons",
|
||||||
|
"resetConfirm": "Reset menu layout to defaults?",
|
||||||
|
"buttonTextPlaceholder": "Custom button text",
|
||||||
|
"customLabelsHint": "Empty = default label",
|
||||||
|
"moveUp": "Move up",
|
||||||
|
"moveDown": "Move down",
|
||||||
|
"openIn": "Open in",
|
||||||
|
"openMode": {
|
||||||
|
"external": "External browser",
|
||||||
|
"webapp": "Telegram miniapp"
|
||||||
|
},
|
||||||
|
"invalidUrl": "Please provide a valid URL (http:// or https://) for all custom buttons"
|
||||||
|
},
|
||||||
"apps": {
|
"apps": {
|
||||||
"title": "App Management",
|
"title": "App Management",
|
||||||
"addApp": "Add App",
|
"addApp": "Add App",
|
||||||
@@ -2524,6 +2549,7 @@
|
|||||||
"form": {
|
"form": {
|
||||||
"name": "Group name",
|
"name": "Group name",
|
||||||
"namePlaceholder": "VIP customers",
|
"namePlaceholder": "VIP customers",
|
||||||
|
"nameRequired": "Enter group name",
|
||||||
"categoryDiscounts": "Category discounts",
|
"categoryDiscounts": "Category discounts",
|
||||||
"periodDiscounts": "Period discounts",
|
"periodDiscounts": "Period discounts",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
@@ -2535,6 +2561,7 @@
|
|||||||
"rub": "rub.",
|
"rub": "rub.",
|
||||||
"autoAssignHint": "0 = don't auto-assign",
|
"autoAssignHint": "0 = don't auto-assign",
|
||||||
"applyToAddons": "Apply to additional services",
|
"applyToAddons": "Apply to additional services",
|
||||||
|
"isDefault": "Default group",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"saving": "Saving...",
|
"saving": "Saving...",
|
||||||
"save": "Save"
|
"save": "Save"
|
||||||
@@ -3293,7 +3320,7 @@
|
|||||||
"status_failed": "Failed",
|
"status_failed": "Failed",
|
||||||
"status_expired": "Expired",
|
"status_expired": "Expired",
|
||||||
"noPurchases": "No purchases",
|
"noPurchases": "No purchases",
|
||||||
"showing": "Showing {{from}}\u2013{{to}} of {{total}}",
|
"showing": "Showing {{from}}–{{to}} of {{total}}",
|
||||||
"page": "Page {{current}} of {{total}}",
|
"page": "Page {{current}} of {{total}}",
|
||||||
"prev": "Previous",
|
"prev": "Previous",
|
||||||
"next": "Next"
|
"next": "Next"
|
||||||
@@ -4114,5 +4141,63 @@
|
|||||||
"nMonths_one": "{{count}} month",
|
"nMonths_one": "{{count}} month",
|
||||||
"nMonths_other": "{{count}} months"
|
"nMonths_other": "{{count}} months"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"gift": {
|
||||||
|
"title": "Gift Subscription",
|
||||||
|
"subtitle": "Send a VPN subscription as a gift",
|
||||||
|
"choosePeriod": "Choose period",
|
||||||
|
"chooseTariff": "Choose tariff",
|
||||||
|
"recipient": "Recipient",
|
||||||
|
"recipientPlaceholder": "Email or @telegram",
|
||||||
|
"recipientHint": "Enter email or Telegram username",
|
||||||
|
"giftMessage": "Greeting",
|
||||||
|
"giftMessagePlaceholder": "Add a personal message (optional)",
|
||||||
|
"paymentMode": "Payment method",
|
||||||
|
"fromBalance": "From balance",
|
||||||
|
"viaGateway": "Via payment gateway",
|
||||||
|
"yourBalance": "Your balance",
|
||||||
|
"insufficientBalance": "Insufficient funds",
|
||||||
|
"topUpBalance": "Top up balance",
|
||||||
|
"total": "Total",
|
||||||
|
"giftButton": "Send Gift",
|
||||||
|
"sending": "Sending gift...",
|
||||||
|
"gb": "GB",
|
||||||
|
"devices": "devices",
|
||||||
|
"paymentMethod": "Payment method",
|
||||||
|
"processing": "Processing...",
|
||||||
|
"successTitle": "Gift sent!",
|
||||||
|
"successDesc": "Recipient {{contact}} will be notified",
|
||||||
|
"pendingTitle": "Awaiting payment",
|
||||||
|
"pendingDesc": "Complete the payment in the payment system",
|
||||||
|
"pendingActivationTitle": "Pending activation",
|
||||||
|
"pendingActivationDesc": "Recipient has an active subscription. Gift is pending activation.",
|
||||||
|
"failedTitle": "Error",
|
||||||
|
"failedDesc": "Failed to send gift. Please try again.",
|
||||||
|
"backToGift": "Go back",
|
||||||
|
"backToDashboard": "Back to dashboard",
|
||||||
|
"tryAgain": "Try again",
|
||||||
|
"pollTimeout": "Processing is taking longer than usual",
|
||||||
|
"pollTimeoutDesc": "Try checking the status later",
|
||||||
|
"pollErrorTitle": "Could not check gift status",
|
||||||
|
"pollErrorDesc": "Your purchase was successful. Check your dashboard for details.",
|
||||||
|
"retry": "Check again",
|
||||||
|
"notFound": "Gift configuration not found",
|
||||||
|
"noToken": "Invalid link",
|
||||||
|
"noTokenDesc": "This gift link is invalid or has expired.",
|
||||||
|
"days": "days",
|
||||||
|
"tariff": "Tariff",
|
||||||
|
"period": "Period",
|
||||||
|
"giftMessageLabel": "Message",
|
||||||
|
"recipientLabel": "Recipient",
|
||||||
|
"featureDisabled": "Gift feature is temporarily unavailable",
|
||||||
|
"redirecting": "Redirecting...",
|
||||||
|
"pending": {
|
||||||
|
"title": "You received a gift!",
|
||||||
|
"from": "from {{sender}}",
|
||||||
|
"activate": "Activate"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"telegram_unresolvable": "Could not verify this Telegram username. The recipient may not receive a notification about the gift."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,8 @@
|
|||||||
"polls": "نظرسنجی",
|
"polls": "نظرسنجی",
|
||||||
"info": "اطلاعات",
|
"info": "اطلاعات",
|
||||||
"wheel": "چرخ شانس",
|
"wheel": "چرخ شانس",
|
||||||
"menu": "منو"
|
"menu": "منو",
|
||||||
|
"gift": "هدیه"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"ticketNotifications": "اعلانهای تیکت",
|
"ticketNotifications": "اعلانهای تیکت",
|
||||||
@@ -1366,6 +1367,8 @@
|
|||||||
"darkTheme": "پوسته تیره",
|
"darkTheme": "پوسته تیره",
|
||||||
"emailAuth": "احراز هویت ایمیل",
|
"emailAuth": "احراز هویت ایمیل",
|
||||||
"emailAuthDesc": "اجازه ورود با ایمیل",
|
"emailAuthDesc": "اجازه ورود با ایمیل",
|
||||||
|
"giftEnabled": "اشتراک هدیه",
|
||||||
|
"giftEnabledDesc": "امکان ارسال اشتراک به عنوان هدیه به کاربر دیگر",
|
||||||
"favoritesEmpty": "هنوز علاقهمندی ندارید",
|
"favoritesEmpty": "هنوز علاقهمندی ندارید",
|
||||||
"favoritesHint": "ستاره بزنید تا تنظیمات اینجا اضافه شوند",
|
"favoritesHint": "ستاره بزنید تا تنظیمات اینجا اضافه شوند",
|
||||||
"interfaceOptions": "گزینههای رابط کاربری",
|
"interfaceOptions": "گزینههای رابط کاربری",
|
||||||
@@ -1407,7 +1410,8 @@
|
|||||||
"referral": "معرفی",
|
"referral": "معرفی",
|
||||||
"support": "پشتیبانی",
|
"support": "پشتیبانی",
|
||||||
"info": "اطلاعات",
|
"info": "اطلاعات",
|
||||||
"admin": "پنل مدیریت"
|
"admin": "پنل مدیریت",
|
||||||
|
"language": "زبان"
|
||||||
},
|
},
|
||||||
"descriptions": {
|
"descriptions": {
|
||||||
"home": "دکمه کابینت شخصی",
|
"home": "دکمه کابینت شخصی",
|
||||||
@@ -1416,7 +1420,8 @@
|
|||||||
"referral": "برنامه معرفی",
|
"referral": "برنامه معرفی",
|
||||||
"support": "تیکتهای پشتیبانی",
|
"support": "تیکتهای پشتیبانی",
|
||||||
"info": "بخش اطلاعات",
|
"info": "بخش اطلاعات",
|
||||||
"admin": "پنل مدیریت وب"
|
"admin": "پنل مدیریت وب",
|
||||||
|
"language": "انتخاب زبان ربات"
|
||||||
},
|
},
|
||||||
"styles": {
|
"styles": {
|
||||||
"default": "بدون رنگ",
|
"default": "بدون رنگ",
|
||||||
@@ -1425,6 +1430,26 @@
|
|||||||
"danger": "قرمز"
|
"danger": "قرمز"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"menuEditor": {
|
||||||
|
"dragHint": "ردیفها را بکشید تا ترتیب تغییر کند",
|
||||||
|
"dragToReorder": "برای مرتبسازی بکشید",
|
||||||
|
"row": "ردیف",
|
||||||
|
"addRow": "افزودن ردیف",
|
||||||
|
"addButton": "افزودن دکمه",
|
||||||
|
"addUrlButton": "دکمه URL (لینک)",
|
||||||
|
"builtinButtons": "بخشهای داخلی",
|
||||||
|
"resetConfirm": "منو به تنظیمات پیشفرض بازنشانی شود؟ تمام تغییرات از بین میرود.",
|
||||||
|
"buttonTextPlaceholder": "متن دکمه",
|
||||||
|
"customLabelsHint": "متن دکمه برای هر زبان ربات",
|
||||||
|
"moveUp": "انتقال به بالا",
|
||||||
|
"moveDown": "انتقال به پایین",
|
||||||
|
"openIn": "باز کردن در",
|
||||||
|
"openMode": {
|
||||||
|
"external": "مرورگر خارجی",
|
||||||
|
"webapp": "مینیاپ تلگرام"
|
||||||
|
},
|
||||||
|
"invalidUrl": "لطفاً برای تمام دکمههای سفارشی یک URL معتبر وارد کنید (http:// یا https://)"
|
||||||
|
},
|
||||||
"apps": {
|
"apps": {
|
||||||
"title": "مدیریت برنامهها",
|
"title": "مدیریت برنامهها",
|
||||||
"addApp": "افزودن برنامه",
|
"addApp": "افزودن برنامه",
|
||||||
@@ -2186,6 +2211,7 @@
|
|||||||
"form": {
|
"form": {
|
||||||
"name": "نام گروه",
|
"name": "نام گروه",
|
||||||
"namePlaceholder": "مشتریان VIP",
|
"namePlaceholder": "مشتریان VIP",
|
||||||
|
"nameRequired": "نام گروه را وارد کنید",
|
||||||
"categoryDiscounts": "تخفیفهای دستهبندی",
|
"categoryDiscounts": "تخفیفهای دستهبندی",
|
||||||
"periodDiscounts": "تخفیفهای دورهای",
|
"periodDiscounts": "تخفیفهای دورهای",
|
||||||
"add": "افزودن",
|
"add": "افزودن",
|
||||||
@@ -2197,6 +2223,7 @@
|
|||||||
"rub": "روبل",
|
"rub": "روبل",
|
||||||
"autoAssignHint": "0 = تخصیص خودکار نشود",
|
"autoAssignHint": "0 = تخصیص خودکار نشود",
|
||||||
"applyToAddons": "اعمال به خدمات اضافی",
|
"applyToAddons": "اعمال به خدمات اضافی",
|
||||||
|
"isDefault": "گروه پیشفرض",
|
||||||
"cancel": "انصراف",
|
"cancel": "انصراف",
|
||||||
"saving": "در حال ذخیره...",
|
"saving": "در حال ذخیره...",
|
||||||
"save": "ذخیره"
|
"save": "ذخیره"
|
||||||
@@ -2964,10 +2991,10 @@
|
|||||||
"content": "محتوا",
|
"content": "محتوا",
|
||||||
"save": "ذخیره",
|
"save": "ذخیره",
|
||||||
"back": "بازگشت",
|
"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",
|
"invalidSlug": "Slug فقط میتواند شامل حروف کوچک، اعداد و خط تیره باشد",
|
||||||
"titleRequired": "\u0639\u0646\u0648\u0627\u0646 \u0627\u0644\u0632\u0627\u0645\u06cc \u0627\u0633\u062a",
|
"titleRequired": "عنوان الزامی است",
|
||||||
"noTariffs": "\u062d\u062f\u0627\u0642\u0644 \u06cc\u06a9 \u062a\u0639\u0631\u0641\u0647 \u0627\u0646\u062a\u062e\u0627\u0628 \u06a9\u0646\u06cc\u062f",
|
"noTariffs": "حداقل یک تعرفه انتخاب کنید",
|
||||||
"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",
|
"noPaymentMethods": "حداقل یک روش پرداخت اضافه کنید",
|
||||||
"selectMethods": "انتخاب روشهای پرداخت موجود",
|
"selectMethods": "انتخاب روشهای پرداخت موجود",
|
||||||
"noSystemMethods": "هیچ روش پرداختی در سیستم پیکربندی نشده است",
|
"noSystemMethods": "هیچ روش پرداختی در سیستم پیکربندی نشده است",
|
||||||
"methodOrder": "برای تغییر ترتیب بکشید",
|
"methodOrder": "برای تغییر ترتیب بکشید",
|
||||||
@@ -3568,9 +3595,9 @@
|
|||||||
"copy": "کپی",
|
"copy": "کپی",
|
||||||
"copied": "کپی شد!",
|
"copied": "کپی شد!",
|
||||||
"copyLink": "کپی لینک",
|
"copyLink": "کپی لینک",
|
||||||
"giftToggleLabel": "\u0646\u0648\u0639 \u062e\u0631\u06cc\u062f",
|
"giftToggleLabel": "نوع خرید",
|
||||||
"pollTimedOut": "\u0632\u0645\u0627\u0646 \u0628\u06cc\u0634\u062a\u0631\u06cc \u0637\u0648\u0644 \u06a9\u0634\u06cc\u062f",
|
"pollTimedOut": "زمان بیشتری طول کشید",
|
||||||
"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.",
|
"pollTimedOutDesc": "پردازش پرداخت بیشتر از حد معمول طول کشیده است. میتوانید دوباره بررسی کنید.",
|
||||||
"pendingActivation": "اشتراک آماده فعالسازی",
|
"pendingActivation": "اشتراک آماده فعالسازی",
|
||||||
"pendingActivationDesc": "شما قبلاً اشتراک فعالی دارید. فعالسازی اشتراک جدید جایگزین اشتراک فعلی میشود.",
|
"pendingActivationDesc": "شما قبلاً اشتراک فعالی دارید. فعالسازی اشتراک جدید جایگزین اشتراک فعلی میشود.",
|
||||||
"activateNow": "فعالسازی",
|
"activateNow": "فعالسازی",
|
||||||
@@ -3609,5 +3636,63 @@
|
|||||||
"nDays": "{{count}} روز",
|
"nDays": "{{count}} روز",
|
||||||
"nMonths": "{{count}} ماه"
|
"nMonths": "{{count}} ماه"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"gift": {
|
||||||
|
"title": "هدیه اشتراک",
|
||||||
|
"subtitle": "اشتراک VPN را به عنوان هدیه ارسال کنید",
|
||||||
|
"choosePeriod": "انتخاب مدت",
|
||||||
|
"chooseTariff": "انتخاب طرح",
|
||||||
|
"recipient": "گیرنده",
|
||||||
|
"recipientPlaceholder": "ایمیل یا @telegram",
|
||||||
|
"recipientHint": "ایمیل یا نام کاربری تلگرام را وارد کنید",
|
||||||
|
"giftMessage": "پیام تبریک",
|
||||||
|
"giftMessagePlaceholder": "پیام شخصی اضافه کنید (اختیاری)",
|
||||||
|
"paymentMode": "روش پرداخت",
|
||||||
|
"fromBalance": "از موجودی",
|
||||||
|
"viaGateway": "درگاه پرداخت",
|
||||||
|
"yourBalance": "موجودی شما",
|
||||||
|
"insufficientBalance": "موجودی ناکافی",
|
||||||
|
"topUpBalance": "شارژ موجودی",
|
||||||
|
"total": "جمع",
|
||||||
|
"giftButton": "ارسال هدیه",
|
||||||
|
"sending": "در حال ارسال هدیه...",
|
||||||
|
"gb": "گیگابایت",
|
||||||
|
"devices": "دستگاه",
|
||||||
|
"paymentMethod": "روش پرداخت",
|
||||||
|
"processing": "در حال پردازش...",
|
||||||
|
"successTitle": "هدیه ارسال شد!",
|
||||||
|
"successDesc": "گیرنده {{contact}} اطلاعرسانی خواهد شد",
|
||||||
|
"pendingTitle": "در انتظار پرداخت",
|
||||||
|
"pendingDesc": "پرداخت را در سیستم پرداخت تکمیل کنید",
|
||||||
|
"pendingActivationTitle": "در انتظار فعالسازی",
|
||||||
|
"pendingActivationDesc": "گیرنده اشتراک فعال دارد. هدیه منتظر فعالسازی است.",
|
||||||
|
"failedTitle": "خطا",
|
||||||
|
"failedDesc": "ارسال هدیه ناموفق بود. لطفاً دوباره تلاش کنید.",
|
||||||
|
"backToGift": "بازگشت",
|
||||||
|
"backToDashboard": "بازگشت به داشبورد",
|
||||||
|
"tryAgain": "تلاش مجدد",
|
||||||
|
"pollTimeout": "پردازش بیش از حد معمول طول کشیده",
|
||||||
|
"pollTimeoutDesc": "بعداً وضعیت را بررسی کنید",
|
||||||
|
"pollErrorTitle": "بررسی وضعیت هدیه امکانپذیر نیست",
|
||||||
|
"pollErrorDesc": "خرید شما موفقیتآمیز بود. وضعیت را در داشبورد بررسی کنید.",
|
||||||
|
"retry": "بررسی مجدد",
|
||||||
|
"notFound": "تنظیمات هدیه یافت نشد",
|
||||||
|
"noToken": "لینک نامعتبر",
|
||||||
|
"noTokenDesc": "این لینک هدیه نامعتبر است یا منقضی شده.",
|
||||||
|
"days": "روز",
|
||||||
|
"tariff": "طرح",
|
||||||
|
"period": "مدت",
|
||||||
|
"giftMessageLabel": "پیام",
|
||||||
|
"recipientLabel": "گیرنده",
|
||||||
|
"featureDisabled": "قابلیت هدیه موقتاً در دسترس نیست",
|
||||||
|
"redirecting": "در حال انتقال...",
|
||||||
|
"pending": {
|
||||||
|
"title": "شما یک هدیه دریافت کردید!",
|
||||||
|
"from": "از {{sender}}",
|
||||||
|
"activate": "فعالسازی"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"telegram_unresolvable": "نام کاربری تلگرام قابل تأیید نبود. ممکن است گیرنده اعلان هدیه را دریافت نکند."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,8 @@
|
|||||||
"polls": "Опросы",
|
"polls": "Опросы",
|
||||||
"info": "Информация",
|
"info": "Информация",
|
||||||
"wheel": "Колесо удачи",
|
"wheel": "Колесо удачи",
|
||||||
"menu": "Меню"
|
"menu": "Меню",
|
||||||
|
"gift": "Подарить"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"ticketNotifications": "Уведомления о тикетах",
|
"ticketNotifications": "Уведомления о тикетах",
|
||||||
@@ -1705,6 +1706,8 @@
|
|||||||
"autoFullscreenDesc": "В Telegram WebApp",
|
"autoFullscreenDesc": "В Telegram WebApp",
|
||||||
"emailAuth": "Email авторизация",
|
"emailAuth": "Email авторизация",
|
||||||
"emailAuthDesc": "Регистрация и вход через email",
|
"emailAuthDesc": "Регистрация и вход через email",
|
||||||
|
"giftEnabled": "Подписка в подарок",
|
||||||
|
"giftEnabledDesc": "Возможность отправить подписку в подарок другому пользователю",
|
||||||
"availableThemes": "Доступные темы",
|
"availableThemes": "Доступные темы",
|
||||||
"darkTheme": "Тёмная",
|
"darkTheme": "Тёмная",
|
||||||
"lightTheme": "Светлая",
|
"lightTheme": "Светлая",
|
||||||
@@ -2256,7 +2259,8 @@
|
|||||||
"referral": "Рефералы",
|
"referral": "Рефералы",
|
||||||
"support": "Поддержка",
|
"support": "Поддержка",
|
||||||
"info": "Информация",
|
"info": "Информация",
|
||||||
"admin": "Админка"
|
"admin": "Админка",
|
||||||
|
"language": "Язык"
|
||||||
},
|
},
|
||||||
"descriptions": {
|
"descriptions": {
|
||||||
"home": "Кнопка личного кабинета",
|
"home": "Кнопка личного кабинета",
|
||||||
@@ -2265,7 +2269,8 @@
|
|||||||
"referral": "Реферальная программа",
|
"referral": "Реферальная программа",
|
||||||
"support": "Обращения в поддержку",
|
"support": "Обращения в поддержку",
|
||||||
"info": "Информационный раздел",
|
"info": "Информационный раздел",
|
||||||
"admin": "Веб-админка"
|
"admin": "Веб-админка",
|
||||||
|
"language": "Выбор языка"
|
||||||
},
|
},
|
||||||
"styles": {
|
"styles": {
|
||||||
"default": "Без цвета",
|
"default": "Без цвета",
|
||||||
@@ -2274,6 +2279,26 @@
|
|||||||
"danger": "Красный"
|
"danger": "Красный"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"menuEditor": {
|
||||||
|
"dragHint": "Перетаскивайте ряды для изменения порядка",
|
||||||
|
"dragToReorder": "Перетащите для сортировки",
|
||||||
|
"row": "Ряд",
|
||||||
|
"addRow": "Добавить ряд",
|
||||||
|
"addButton": "Добавить кнопку",
|
||||||
|
"addUrlButton": "URL-кнопка (ссылка)",
|
||||||
|
"builtinButtons": "Встроенные кнопки",
|
||||||
|
"resetConfirm": "Сбросить компоновку меню к значениям по умолчанию?",
|
||||||
|
"buttonTextPlaceholder": "Свой текст кнопки",
|
||||||
|
"customLabelsHint": "Пустое поле = название по умолчанию",
|
||||||
|
"moveUp": "Переместить вверх",
|
||||||
|
"moveDown": "Переместить вниз",
|
||||||
|
"openIn": "Открывать в",
|
||||||
|
"openMode": {
|
||||||
|
"external": "Внешний браузер",
|
||||||
|
"webapp": "Telegram miniapp"
|
||||||
|
},
|
||||||
|
"invalidUrl": "Укажите корректный URL (http:// или https://) для всех пользовательских кнопок"
|
||||||
|
},
|
||||||
"apps": {
|
"apps": {
|
||||||
"title": "Управление приложениями",
|
"title": "Управление приложениями",
|
||||||
"addApp": "Добавить приложение",
|
"addApp": "Добавить приложение",
|
||||||
@@ -3045,6 +3070,7 @@
|
|||||||
"form": {
|
"form": {
|
||||||
"name": "Название группы",
|
"name": "Название группы",
|
||||||
"namePlaceholder": "VIP клиенты",
|
"namePlaceholder": "VIP клиенты",
|
||||||
|
"nameRequired": "Введите название группы",
|
||||||
"categoryDiscounts": "Скидки по категориям",
|
"categoryDiscounts": "Скидки по категориям",
|
||||||
"periodDiscounts": "Скидки по периодам",
|
"periodDiscounts": "Скидки по периодам",
|
||||||
"add": "Добавить",
|
"add": "Добавить",
|
||||||
@@ -3056,6 +3082,7 @@
|
|||||||
"rub": "руб.",
|
"rub": "руб.",
|
||||||
"autoAssignHint": "0 = не назначать автоматически",
|
"autoAssignHint": "0 = не назначать автоматически",
|
||||||
"applyToAddons": "Применять к дополнительным услугам",
|
"applyToAddons": "Применять к дополнительным услугам",
|
||||||
|
"isDefault": "Группа по умолчанию",
|
||||||
"cancel": "Отмена",
|
"cancel": "Отмена",
|
||||||
"saving": "Сохранение...",
|
"saving": "Сохранение...",
|
||||||
"save": "Сохранить"
|
"save": "Сохранить"
|
||||||
@@ -3845,7 +3872,7 @@
|
|||||||
"status_failed": "Ошибка",
|
"status_failed": "Ошибка",
|
||||||
"status_expired": "Истёк",
|
"status_expired": "Истёк",
|
||||||
"noPurchases": "Нет покупок",
|
"noPurchases": "Нет покупок",
|
||||||
"showing": "Показано {{from}}\u2013{{to}} из {{total}}",
|
"showing": "Показано {{from}}–{{to}} из {{total}}",
|
||||||
"page": "Стр. {{current}} из {{total}}",
|
"page": "Стр. {{current}} из {{total}}",
|
||||||
"prev": "Назад",
|
"prev": "Назад",
|
||||||
"next": "Далее"
|
"next": "Далее"
|
||||||
@@ -4676,5 +4703,63 @@
|
|||||||
"nMonths_few": "{{count}} месяца",
|
"nMonths_few": "{{count}} месяца",
|
||||||
"nMonths_many": "{{count}} месяцев"
|
"nMonths_many": "{{count}} месяцев"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"gift": {
|
||||||
|
"title": "Подарить подписку",
|
||||||
|
"subtitle": "Отправьте VPN-подписку в подарок",
|
||||||
|
"choosePeriod": "Выберите период",
|
||||||
|
"chooseTariff": "Выберите тариф",
|
||||||
|
"recipient": "Получатель",
|
||||||
|
"recipientPlaceholder": "Email или @telegram",
|
||||||
|
"recipientHint": "Введите email или юзернейм в Telegram",
|
||||||
|
"giftMessage": "Поздравление",
|
||||||
|
"giftMessagePlaceholder": "Добавьте личное сообщение (необязательно)",
|
||||||
|
"paymentMode": "Способ оплаты",
|
||||||
|
"fromBalance": "С баланса",
|
||||||
|
"viaGateway": "Через платёжку",
|
||||||
|
"yourBalance": "Ваш баланс",
|
||||||
|
"insufficientBalance": "Недостаточно средств",
|
||||||
|
"topUpBalance": "Пополнить баланс",
|
||||||
|
"total": "Итого",
|
||||||
|
"giftButton": "Подарить",
|
||||||
|
"sending": "Отправляем подарок...",
|
||||||
|
"gb": "ГБ",
|
||||||
|
"devices": "устройств",
|
||||||
|
"paymentMethod": "Способ оплаты",
|
||||||
|
"processing": "Обработка...",
|
||||||
|
"successTitle": "Подарок отправлен!",
|
||||||
|
"successDesc": "Получатель {{contact}} получит уведомление",
|
||||||
|
"pendingTitle": "Ожидание оплаты",
|
||||||
|
"pendingDesc": "Завершите оплату в платёжной системе",
|
||||||
|
"pendingActivationTitle": "Ожидает активации",
|
||||||
|
"pendingActivationDesc": "У получателя есть активная подписка. Подарок ожидает активации.",
|
||||||
|
"failedTitle": "Ошибка",
|
||||||
|
"failedDesc": "Не удалось отправить подарок. Попробуйте снова.",
|
||||||
|
"backToGift": "Вернуться",
|
||||||
|
"backToDashboard": "На главную",
|
||||||
|
"tryAgain": "Попробовать снова",
|
||||||
|
"pollTimeout": "Обработка занимает больше времени, чем обычно",
|
||||||
|
"pollTimeoutDesc": "Попробуйте проверить статус позже",
|
||||||
|
"pollErrorTitle": "Не удалось проверить статус подарка",
|
||||||
|
"pollErrorDesc": "Ваша покупка прошла успешно. Проверьте статус на главной странице.",
|
||||||
|
"retry": "Проверить снова",
|
||||||
|
"notFound": "Конфигурация подарков не найдена",
|
||||||
|
"noToken": "Ссылка недействительна",
|
||||||
|
"noTokenDesc": "Ссылка на подарок недействительна или просрочена.",
|
||||||
|
"days": "дн.",
|
||||||
|
"tariff": "Тариф",
|
||||||
|
"period": "Период",
|
||||||
|
"giftMessageLabel": "Сообщение",
|
||||||
|
"recipientLabel": "Получатель",
|
||||||
|
"featureDisabled": "Функция подарков временно недоступна",
|
||||||
|
"redirecting": "Перенаправляем...",
|
||||||
|
"pending": {
|
||||||
|
"title": "Вам подарили подписку!",
|
||||||
|
"from": "от {{sender}}",
|
||||||
|
"activate": "Активировать"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"telegram_unresolvable": "Не удалось проверить этот Telegram-юзернейм. Получатель может не получить уведомление о подарке."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,8 @@
|
|||||||
"polls": "问卷",
|
"polls": "问卷",
|
||||||
"info": "信息",
|
"info": "信息",
|
||||||
"wheel": "幸运转盘",
|
"wheel": "幸运转盘",
|
||||||
"menu": "菜单"
|
"menu": "菜单",
|
||||||
|
"gift": "赠送"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"ticketNotifications": "工单通知",
|
"ticketNotifications": "工单通知",
|
||||||
@@ -1404,6 +1405,8 @@
|
|||||||
"darkTheme": "暗色主题",
|
"darkTheme": "暗色主题",
|
||||||
"emailAuth": "邮箱认证",
|
"emailAuth": "邮箱认证",
|
||||||
"emailAuthDesc": "允许通过邮箱登录",
|
"emailAuthDesc": "允许通过邮箱登录",
|
||||||
|
"giftEnabled": "赠送订阅",
|
||||||
|
"giftEnabledDesc": "允许用户将订阅作为礼物赠送给其他用户",
|
||||||
"favoritesEmpty": "暂无收藏",
|
"favoritesEmpty": "暂无收藏",
|
||||||
"favoritesHint": "点击星标将设置添加到这里",
|
"favoritesHint": "点击星标将设置添加到这里",
|
||||||
"interfaceOptions": "界面选项",
|
"interfaceOptions": "界面选项",
|
||||||
@@ -1445,7 +1448,8 @@
|
|||||||
"referral": "推荐",
|
"referral": "推荐",
|
||||||
"support": "支持",
|
"support": "支持",
|
||||||
"info": "信息",
|
"info": "信息",
|
||||||
"admin": "管理面板"
|
"admin": "管理面板",
|
||||||
|
"language": "语言"
|
||||||
},
|
},
|
||||||
"descriptions": {
|
"descriptions": {
|
||||||
"home": "个人中心按钮",
|
"home": "个人中心按钮",
|
||||||
@@ -1454,7 +1458,8 @@
|
|||||||
"referral": "推荐计划",
|
"referral": "推荐计划",
|
||||||
"support": "支持工单",
|
"support": "支持工单",
|
||||||
"info": "信息板块",
|
"info": "信息板块",
|
||||||
"admin": "网页管理面板"
|
"admin": "网页管理面板",
|
||||||
|
"language": "机器人语言选择"
|
||||||
},
|
},
|
||||||
"styles": {
|
"styles": {
|
||||||
"default": "无颜色",
|
"default": "无颜色",
|
||||||
@@ -1463,6 +1468,26 @@
|
|||||||
"danger": "红色"
|
"danger": "红色"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"menuEditor": {
|
||||||
|
"dragHint": "拖拽行以重新排序",
|
||||||
|
"dragToReorder": "拖拽排序",
|
||||||
|
"row": "行",
|
||||||
|
"addRow": "添加行",
|
||||||
|
"addButton": "添加按钮",
|
||||||
|
"addUrlButton": "URL按钮(链接)",
|
||||||
|
"builtinButtons": "内置栏目",
|
||||||
|
"resetConfirm": "将菜单重置为默认设置?所有更改将丢失。",
|
||||||
|
"buttonTextPlaceholder": "按钮文本",
|
||||||
|
"customLabelsHint": "每种语言的按钮文本",
|
||||||
|
"moveUp": "上移",
|
||||||
|
"moveDown": "下移",
|
||||||
|
"openIn": "打开方式",
|
||||||
|
"openMode": {
|
||||||
|
"external": "外部浏览器",
|
||||||
|
"webapp": "Telegram 小程序"
|
||||||
|
},
|
||||||
|
"invalidUrl": "请为所有自定义按钮提供有效的URL(http:// 或 https://)"
|
||||||
|
},
|
||||||
"apps": {
|
"apps": {
|
||||||
"title": "应用管理",
|
"title": "应用管理",
|
||||||
"addApp": "添加应用",
|
"addApp": "添加应用",
|
||||||
@@ -2185,6 +2210,7 @@
|
|||||||
"form": {
|
"form": {
|
||||||
"name": "组名称",
|
"name": "组名称",
|
||||||
"namePlaceholder": "VIP客户",
|
"namePlaceholder": "VIP客户",
|
||||||
|
"nameRequired": "请输入组名称",
|
||||||
"categoryDiscounts": "分类折扣",
|
"categoryDiscounts": "分类折扣",
|
||||||
"periodDiscounts": "期间折扣",
|
"periodDiscounts": "期间折扣",
|
||||||
"add": "添加",
|
"add": "添加",
|
||||||
@@ -2196,6 +2222,7 @@
|
|||||||
"rub": "卢布",
|
"rub": "卢布",
|
||||||
"autoAssignHint": "0 = 不自动分配",
|
"autoAssignHint": "0 = 不自动分配",
|
||||||
"applyToAddons": "应用于附加服务",
|
"applyToAddons": "应用于附加服务",
|
||||||
|
"isDefault": "默认组",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"saving": "保存中...",
|
"saving": "保存中...",
|
||||||
"save": "保存"
|
"save": "保存"
|
||||||
@@ -2963,10 +2990,10 @@
|
|||||||
"content": "内容",
|
"content": "内容",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"back": "返回",
|
"back": "返回",
|
||||||
"invalidSlug": "Slug\u53ea\u80fd\u5305\u542b\u5c0f\u5199\u5b57\u6bcd\u3001\u6570\u5b57\u548c\u8fde\u5b57\u7b26",
|
"invalidSlug": "Slug只能包含小写字母、数字和连字符",
|
||||||
"titleRequired": "\u6807\u9898\u4e3a\u5fc5\u586b\u9879",
|
"titleRequired": "标题为必填项",
|
||||||
"noTariffs": "\u8bf7\u81f3\u5c11\u9009\u62e9\u4e00\u4e2a\u5957\u9910",
|
"noTariffs": "请至少选择一个套餐",
|
||||||
"noPaymentMethods": "\u8bf7\u81f3\u5c11\u6dfb\u52a0\u4e00\u4e2a\u652f\u4ed8\u65b9\u5f0f",
|
"noPaymentMethods": "请至少添加一个支付方式",
|
||||||
"selectMethods": "选择可用的支付方式",
|
"selectMethods": "选择可用的支付方式",
|
||||||
"noSystemMethods": "系统中未配置任何支付方式",
|
"noSystemMethods": "系统中未配置任何支付方式",
|
||||||
"methodOrder": "拖动以调整顺序",
|
"methodOrder": "拖动以调整顺序",
|
||||||
@@ -3567,9 +3594,9 @@
|
|||||||
"copy": "复制",
|
"copy": "复制",
|
||||||
"copied": "已复制!",
|
"copied": "已复制!",
|
||||||
"copyLink": "复制链接",
|
"copyLink": "复制链接",
|
||||||
"giftToggleLabel": "\u8d2d\u4e70\u7c7b\u578b",
|
"giftToggleLabel": "购买类型",
|
||||||
"pollTimedOut": "\u5904\u7406\u65f6\u95f4\u8f83\u957f",
|
"pollTimedOut": "处理时间较长",
|
||||||
"pollTimedOutDesc": "\u4ed8\u6b3e\u5904\u7406\u65f6\u95f4\u6bd4\u5e73\u65f6\u957f\u3002\u60a8\u53ef\u4ee5\u5c1d\u8bd5\u518d\u6b21\u68c0\u67e5\u3002",
|
"pollTimedOutDesc": "付款处理时间比平时长。您可以尝试再次检查。",
|
||||||
"pendingActivation": "订阅准备激活",
|
"pendingActivation": "订阅准备激活",
|
||||||
"pendingActivationDesc": "您已有活跃订阅。激活新订阅将替换当前订阅。",
|
"pendingActivationDesc": "您已有活跃订阅。激活新订阅将替换当前订阅。",
|
||||||
"activateNow": "立即激活",
|
"activateNow": "立即激活",
|
||||||
@@ -3608,5 +3635,63 @@
|
|||||||
"nDays": "{{count}}天",
|
"nDays": "{{count}}天",
|
||||||
"nMonths": "{{count}}个月"
|
"nMonths": "{{count}}个月"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"gift": {
|
||||||
|
"title": "赠送订阅",
|
||||||
|
"subtitle": "将VPN订阅作为礼物发送",
|
||||||
|
"choosePeriod": "选择时长",
|
||||||
|
"chooseTariff": "选择套餐",
|
||||||
|
"recipient": "收件人",
|
||||||
|
"recipientPlaceholder": "邮箱或 @telegram",
|
||||||
|
"recipientHint": "输入邮箱或 Telegram 用户名",
|
||||||
|
"giftMessage": "祝福语",
|
||||||
|
"giftMessagePlaceholder": "添加个人消息(可选)",
|
||||||
|
"paymentMode": "支付方式",
|
||||||
|
"fromBalance": "余额支付",
|
||||||
|
"viaGateway": "在线支付",
|
||||||
|
"yourBalance": "您的余额",
|
||||||
|
"insufficientBalance": "余额不足",
|
||||||
|
"topUpBalance": "充值",
|
||||||
|
"total": "合计",
|
||||||
|
"giftButton": "赠送",
|
||||||
|
"sending": "正在发送礼物...",
|
||||||
|
"gb": "GB",
|
||||||
|
"devices": "设备",
|
||||||
|
"paymentMethod": "支付方式",
|
||||||
|
"processing": "处理中...",
|
||||||
|
"successTitle": "礼物已发送!",
|
||||||
|
"successDesc": "收件人 {{contact}} 将收到通知",
|
||||||
|
"pendingTitle": "等待支付",
|
||||||
|
"pendingDesc": "请在支付系统中完成支付",
|
||||||
|
"pendingActivationTitle": "等待激活",
|
||||||
|
"pendingActivationDesc": "收件人有活跃订阅,礼物等待激活。",
|
||||||
|
"failedTitle": "错误",
|
||||||
|
"failedDesc": "礼物发送失败,请重试。",
|
||||||
|
"backToGift": "返回",
|
||||||
|
"backToDashboard": "返回首页",
|
||||||
|
"tryAgain": "重试",
|
||||||
|
"pollTimeout": "处理时间超出预期",
|
||||||
|
"pollTimeoutDesc": "请稍后再查看状态",
|
||||||
|
"pollErrorTitle": "无法检查礼物状态",
|
||||||
|
"pollErrorDesc": "您的购买已成功。请在仪表板上查看详情。",
|
||||||
|
"retry": "再次检查",
|
||||||
|
"notFound": "未找到礼物配置",
|
||||||
|
"noToken": "无效链接",
|
||||||
|
"noTokenDesc": "此礼物链接无效或已过期。",
|
||||||
|
"days": "天",
|
||||||
|
"tariff": "套餐",
|
||||||
|
"period": "时长",
|
||||||
|
"giftMessageLabel": "消息",
|
||||||
|
"recipientLabel": "收件人",
|
||||||
|
"featureDisabled": "礼物功能暂时不可用",
|
||||||
|
"redirecting": "正在跳转...",
|
||||||
|
"pending": {
|
||||||
|
"title": "您收到了一份礼物!",
|
||||||
|
"from": "来自 {{sender}}",
|
||||||
|
"activate": "激活"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"telegram_unresolvable": "无法验证此 Telegram 用户名。收件人可能不会收到礼物通知。"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ export default function AdminPayments() {
|
|||||||
<a
|
<a
|
||||||
href={payment.payment_url}
|
href={payment.payment_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener"
|
||||||
className="btn-secondary px-3 py-1.5 text-xs"
|
className="btn-secondary px-3 py-1.5 text-xs"
|
||||||
>
|
>
|
||||||
{t('admin.payments.openLink')}
|
{t('admin.payments.openLink')}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export default function AdminPromoGroupCreate() {
|
|||||||
const [trafficDiscount, setTrafficDiscount] = useState<number | ''>(0);
|
const [trafficDiscount, setTrafficDiscount] = useState<number | ''>(0);
|
||||||
const [deviceDiscount, setDeviceDiscount] = useState<number | ''>(0);
|
const [deviceDiscount, setDeviceDiscount] = useState<number | ''>(0);
|
||||||
const [applyToAddons, setApplyToAddons] = useState(true);
|
const [applyToAddons, setApplyToAddons] = useState(true);
|
||||||
|
const [isDefault, setIsDefault] = useState(false);
|
||||||
const [autoAssignSpent, setAutoAssignSpent] = useState<number | ''>(0);
|
const [autoAssignSpent, setAutoAssignSpent] = useState<number | ''>(0);
|
||||||
const [periodDiscounts, setPeriodDiscounts] = useState<PeriodDiscount[]>([]);
|
const [periodDiscounts, setPeriodDiscounts] = useState<PeriodDiscount[]>([]);
|
||||||
|
|
||||||
@@ -79,6 +80,7 @@ export default function AdminPromoGroupCreate() {
|
|||||||
setTrafficDiscount(data.traffic_discount_percent || 0);
|
setTrafficDiscount(data.traffic_discount_percent || 0);
|
||||||
setDeviceDiscount(data.device_discount_percent || 0);
|
setDeviceDiscount(data.device_discount_percent || 0);
|
||||||
setApplyToAddons(data.apply_discounts_to_addons ?? true);
|
setApplyToAddons(data.apply_discounts_to_addons ?? true);
|
||||||
|
setIsDefault(data.is_default ?? false);
|
||||||
setAutoAssignSpent(
|
setAutoAssignSpent(
|
||||||
data.auto_assign_total_spent_kopeks ? data.auto_assign_total_spent_kopeks / 100 : 0,
|
data.auto_assign_total_spent_kopeks ? data.auto_assign_total_spent_kopeks / 100 : 0,
|
||||||
);
|
);
|
||||||
@@ -151,7 +153,8 @@ export default function AdminPromoGroupCreate() {
|
|||||||
period_discounts:
|
period_discounts:
|
||||||
Object.keys(periodDiscountsRecord).length > 0 ? periodDiscountsRecord : undefined,
|
Object.keys(periodDiscountsRecord).length > 0 ? periodDiscountsRecord : undefined,
|
||||||
apply_discounts_to_addons: applyToAddons,
|
apply_discounts_to_addons: applyToAddons,
|
||||||
auto_assign_total_spent_kopeks: autoAssignVal > 0 ? Math.round(autoAssignVal * 100) : null,
|
auto_assign_total_spent_kopeks: autoAssignVal > 0 ? Math.round(autoAssignVal * 100) : 0,
|
||||||
|
is_default: isDefault,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
@@ -388,6 +391,24 @@ export default function AdminPromoGroupCreate() {
|
|||||||
</button>
|
</button>
|
||||||
<span className="text-sm text-dark-200">{t('admin.promoGroups.form.applyToAddons')}</span>
|
<span className="text-sm text-dark-200">{t('admin.promoGroups.form.applyToAddons')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{/* Default group */}
|
||||||
|
<label className="flex cursor-pointer items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsDefault(!isDefault)}
|
||||||
|
className={`relative h-6 w-11 rounded-full transition-colors ${
|
||||||
|
isDefault ? 'bg-accent-500' : 'bg-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||||
|
isDefault ? 'left-6' : 'left-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-dark-200">{t('admin.promoGroups.form.isDefault')}</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { MENU_SECTIONS, MenuItem, formatSettingKey } from '../components/admin';
|
|||||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
import { AnalyticsTab } from '../components/admin/AnalyticsTab';
|
import { AnalyticsTab } from '../components/admin/AnalyticsTab';
|
||||||
import { BrandingTab } from '../components/admin/BrandingTab';
|
import { BrandingTab } from '../components/admin/BrandingTab';
|
||||||
import { ButtonsTab } from '../components/admin/ButtonsTab';
|
import { MenuEditorTab } from '../components/admin/MenuEditorTab';
|
||||||
import { ThemeTab } from '../components/admin/ThemeTab';
|
import { ThemeTab } from '../components/admin/ThemeTab';
|
||||||
import { FavoritesTab } from '../components/admin/FavoritesTab';
|
import { FavoritesTab } from '../components/admin/FavoritesTab';
|
||||||
import { SettingsTab } from '../components/admin/SettingsTab';
|
import { SettingsTab } from '../components/admin/SettingsTab';
|
||||||
@@ -176,7 +176,7 @@ export default function AdminSettings() {
|
|||||||
case 'theme':
|
case 'theme':
|
||||||
return <ThemeTab />;
|
return <ThemeTab />;
|
||||||
case 'buttons':
|
case 'buttons':
|
||||||
return <ButtonsTab />;
|
return <MenuEditorTab />;
|
||||||
case 'favorites':
|
case 'favorites':
|
||||||
return (
|
return (
|
||||||
<FavoritesTab
|
<FavoritesTab
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { authApi } from '../api/auth';
|
import { authApi } from '../api/auth';
|
||||||
|
import { brandingApi, type TelegramWidgetConfig } from '../api/branding';
|
||||||
import { useToast } from '../components/Toast';
|
import { useToast } from '../components/Toast';
|
||||||
import { Card } from '@/components/data-display/Card';
|
import { Card } from '@/components/data-display/Card';
|
||||||
import { Button } from '@/components/primitives/Button';
|
import { Button } from '@/components/primitives/Button';
|
||||||
@@ -24,28 +25,126 @@ const isLinkableProvider = (provider: string): boolean =>
|
|||||||
// SessionStorage key for Telegram link CSRF state
|
// SessionStorage key for Telegram link CSRF state
|
||||||
export const LINK_TELEGRAM_STATE_KEY = 'link_telegram_state';
|
export const LINK_TELEGRAM_STATE_KEY = 'link_telegram_state';
|
||||||
|
|
||||||
/** Compact Telegram Login Widget for account linking (browser only). */
|
/** Telegram account linking widget (browser only). Supports OIDC popup and legacy widget. */
|
||||||
function TelegramLinkWidget() {
|
function TelegramLinkWidget() {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
|
const [oidcLoading, setOidcLoading] = useState(false);
|
||||||
|
const [scriptLoaded, setScriptLoaded] = useState(false);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current || !botUsername) return;
|
mountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Shared handler for link result
|
||||||
|
const handleLinkResult = async (response: Awaited<ReturnType<typeof authApi.linkTelegram>>) => {
|
||||||
|
if (response.merge_required && response.merge_token) {
|
||||||
|
navigate(`/merge/${response.merge_token}`, { replace: true });
|
||||||
|
} else {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['linked-providers'] });
|
||||||
|
showToast({ type: 'success', message: t('profile.accounts.linkSuccess') });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// OIDC callback handler (ref pattern to avoid stale closures)
|
||||||
|
const handleOIDCCallbackRef =
|
||||||
|
useRef<(data: { id_token?: string; error?: string }) => void>(undefined);
|
||||||
|
|
||||||
|
handleOIDCCallbackRef.current = async (data: { id_token?: string; error?: string }) => {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
if (data.error || !data.id_token) {
|
||||||
|
setOidcLoading(false);
|
||||||
|
showToast({
|
||||||
|
type: 'error',
|
||||||
|
message: data.error || t('profile.accounts.linkError'),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setOidcLoading(true);
|
||||||
|
const response = await authApi.linkTelegram({ id_token: data.id_token });
|
||||||
|
if (mountedRef.current) await handleLinkResult(response);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
showToast({
|
||||||
|
type: 'error',
|
||||||
|
message: getErrorDetail(err) || t('profile.accounts.linkError'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} 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),
|
||||||
|
);
|
||||||
|
setScriptLoaded(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = () => initTelegramLogin();
|
||||||
|
script.onerror = () => {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
showToast({ type: 'error', message: t('profile.accounts.linkError') });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.head.appendChild(script);
|
||||||
|
} else {
|
||||||
|
initTelegramLogin();
|
||||||
|
}
|
||||||
|
}, [isOIDC, widgetConfig?.oidc_client_id, widgetConfig?.request_access]);
|
||||||
|
|
||||||
|
// Legacy widget effect (only when NOT OIDC)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOIDC || !containerRef.current || !botUsername) return;
|
||||||
|
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
while (container.firstChild) {
|
while (container.firstChild) {
|
||||||
container.removeChild(container.firstChild);
|
container.removeChild(container.firstChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global callback invoked by the Telegram Login Widget
|
|
||||||
const callbackName = '__onTelegramLinkAuth';
|
const callbackName = '__onTelegramLinkAuth';
|
||||||
(window as unknown as Record<string, unknown>)[callbackName] = async (
|
(window as unknown as Record<string, unknown>)[callbackName] = async (
|
||||||
user: Record<string, unknown>,
|
user: Record<string, unknown>,
|
||||||
) => {
|
) => {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
try {
|
try {
|
||||||
const response = await authApi.linkTelegram({
|
const response = await authApi.linkTelegram({
|
||||||
id: user.id as number,
|
id: user.id as number,
|
||||||
@@ -56,17 +155,14 @@ function TelegramLinkWidget() {
|
|||||||
auth_date: user.auth_date as number,
|
auth_date: user.auth_date as number,
|
||||||
hash: user.hash as string,
|
hash: user.hash as string,
|
||||||
});
|
});
|
||||||
if (response.merge_required && response.merge_token) {
|
if (mountedRef.current) await handleLinkResult(response);
|
||||||
navigate(`/merge/${response.merge_token}`, { replace: true });
|
|
||||||
} else {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['linked-providers'] });
|
|
||||||
showToast({ type: 'success', message: t('profile.accounts.linkSuccess') });
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
showToast({
|
if (mountedRef.current) {
|
||||||
type: 'error',
|
showToast({
|
||||||
message: getErrorDetail(err) || t('profile.accounts.linkError'),
|
type: 'error',
|
||||||
});
|
message: getErrorDetail(err) || t('profile.accounts.linkError'),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -87,12 +183,33 @@ function TelegramLinkWidget() {
|
|||||||
container.removeChild(container.firstChild);
|
container.removeChild(container.firstChild);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [botUsername, navigate, showToast, t, queryClient]);
|
}, [isOIDC, botUsername, navigate, showToast, t, queryClient]);
|
||||||
|
|
||||||
if (!botUsername) {
|
if (!botUsername && !isOIDC) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isOIDC) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
disabled={oidcLoading || !scriptLoaded}
|
||||||
|
loading={oidcLoading}
|
||||||
|
onClick={() => {
|
||||||
|
setOidcLoading(true);
|
||||||
|
if (window.Telegram?.Login) {
|
||||||
|
window.Telegram.Login.open();
|
||||||
|
} else {
|
||||||
|
setOidcLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('profile.accounts.link')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return <div ref={containerRef} className="flex items-center" />;
|
return <div ref={containerRef} className="flex items-center" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import SubscriptionCardActive from '../components/dashboard/SubscriptionCardActi
|
|||||||
import SubscriptionCardExpired from '../components/dashboard/SubscriptionCardExpired';
|
import SubscriptionCardExpired from '../components/dashboard/SubscriptionCardExpired';
|
||||||
import TrialOfferCard from '../components/dashboard/TrialOfferCard';
|
import TrialOfferCard from '../components/dashboard/TrialOfferCard';
|
||||||
import StatsGrid from '../components/dashboard/StatsGrid';
|
import StatsGrid from '../components/dashboard/StatsGrid';
|
||||||
|
import { giftApi } from '../api/gift';
|
||||||
|
import PendingGiftCard from '../components/dashboard/PendingGiftCard';
|
||||||
import { API } from '../config/constants';
|
import { API } from '../config/constants';
|
||||||
|
|
||||||
const ChevronRightIcon = () => (
|
const ChevronRightIcon = () => (
|
||||||
@@ -80,6 +82,13 @@ export default function Dashboard() {
|
|||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: pendingGifts } = useQuery({
|
||||||
|
queryKey: ['pending-gifts'],
|
||||||
|
queryFn: giftApi.getPendingGifts,
|
||||||
|
staleTime: 30_000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
const activateTrialMutation = useMutation({
|
const activateTrialMutation = useMutation({
|
||||||
mutationFn: subscriptionApi.activateTrial,
|
mutationFn: subscriptionApi.activateTrial,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -221,6 +230,9 @@ export default function Dashboard() {
|
|||||||
<p className="mt-1 text-dark-400">{t('dashboard.yourSubscription')}</p>
|
<p className="mt-1 text-dark-400">{t('dashboard.yourSubscription')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pending Gift Activations */}
|
||||||
|
{pendingGifts && pendingGifts.length > 0 && <PendingGiftCard gifts={pendingGifts} />}
|
||||||
|
|
||||||
{/* Subscription Status Card */}
|
{/* Subscription Status Card */}
|
||||||
{subLoading ? (
|
{subLoading ? (
|
||||||
<div className="bento-card">
|
<div className="bento-card">
|
||||||
|
|||||||
457
src/pages/GiftResult.tsx
Normal file
457
src/pages/GiftResult.tsx
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { useSearchParams, useNavigate } from 'react-router';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { giftApi } from '../api/gift';
|
||||||
|
import { Spinner } from '@/components/ui/Spinner';
|
||||||
|
import { AnimatedCheckmark } from '@/components/ui/AnimatedCheckmark';
|
||||||
|
import { AnimatedCrossmark } from '@/components/ui/AnimatedCrossmark';
|
||||||
|
|
||||||
|
const MAX_POLL_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
|
const KNOWN_WARNINGS = new Set(['telegram_unresolvable']);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Sub-components
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
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('gift.processing', 'Processing your gift...')}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-dark-400">
|
||||||
|
{t('gift.pendingDesc', 'Please wait while we process your payment')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeliveredState({
|
||||||
|
recipientContact,
|
||||||
|
tariffName,
|
||||||
|
periodDays,
|
||||||
|
giftMessage,
|
||||||
|
warning,
|
||||||
|
}: {
|
||||||
|
recipientContact: string | null;
|
||||||
|
tariffName: string | null;
|
||||||
|
periodDays: number | null;
|
||||||
|
giftMessage: string | null;
|
||||||
|
warning: string | null;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="flex flex-col items-center gap-6 text-center"
|
||||||
|
>
|
||||||
|
<AnimatedCheckmark />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-dark-50">{t('gift.successTitle', 'Gift sent!')}</h1>
|
||||||
|
{tariffName && periodDays !== null && (
|
||||||
|
<p className="mt-1 text-sm text-dark-300">
|
||||||
|
{tariffName} — {periodDays} {t('gift.days', 'days')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{recipientContact && (
|
||||||
|
<p className="mt-2 text-sm text-dark-400">
|
||||||
|
{t('gift.successDesc', {
|
||||||
|
contact: recipientContact,
|
||||||
|
defaultValue: `Sent to ${recipientContact}`,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{giftMessage && (
|
||||||
|
<p className="mt-2 text-sm italic text-dark-400">“{giftMessage}”</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{warning && (
|
||||||
|
<div className="w-full rounded-xl border border-warning-500/20 bg-warning-500/5 p-3">
|
||||||
|
<p className="text-sm text-warning-400">{t(`gift.warning.${warning}`)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="flex w-full items-center justify-center rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
|
||||||
|
>
|
||||||
|
{t('gift.backToDashboard', 'Back to dashboard')}
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PendingActivationState({
|
||||||
|
recipientContact,
|
||||||
|
tariffName,
|
||||||
|
periodDays,
|
||||||
|
warning,
|
||||||
|
}: {
|
||||||
|
recipientContact: string | null;
|
||||||
|
tariffName: string | null;
|
||||||
|
periodDays: number | null;
|
||||||
|
warning: string | null;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="flex flex-col items-center gap-6 text-center"
|
||||||
|
>
|
||||||
|
{/* Info 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="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-dark-50">
|
||||||
|
{t('gift.pendingActivationTitle', 'Gift pending activation')}
|
||||||
|
</h1>
|
||||||
|
{tariffName && periodDays !== null && (
|
||||||
|
<p className="mt-1 text-sm text-dark-300">
|
||||||
|
{tariffName} — {periodDays} {t('gift.days', 'days')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{recipientContact && (
|
||||||
|
<p className="mt-2 text-sm text-dark-400">
|
||||||
|
{t('gift.successDesc', {
|
||||||
|
contact: recipientContact,
|
||||||
|
defaultValue: `Sent to ${recipientContact}`,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 text-sm text-dark-400">
|
||||||
|
{t(
|
||||||
|
'gift.pendingActivationDesc',
|
||||||
|
'The recipient currently has an active subscription. Your gift will be activated once their current subscription expires.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{warning && (
|
||||||
|
<div className="w-full rounded-xl border border-warning-500/20 bg-warning-500/5 p-3">
|
||||||
|
<p className="text-sm text-warning-400">{t(`gift.warning.${warning}`)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="flex w-full items-center justify-center rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
|
||||||
|
>
|
||||||
|
{t('gift.backToDashboard', 'Back to dashboard')}
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FailedState() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="flex flex-col items-center gap-6 text-center"
|
||||||
|
>
|
||||||
|
<AnimatedCrossmark />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-dark-50">
|
||||||
|
{t('gift.failedTitle', 'Something went wrong')}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-dark-400">
|
||||||
|
{t('gift.failedDesc', 'Your gift could not be processed. Please try again.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/gift')}
|
||||||
|
className="flex w-full items-center justify-center rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
|
||||||
|
>
|
||||||
|
{t('gift.tryAgain', 'Try again')}
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PollErrorState() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
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-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.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-dark-50">
|
||||||
|
{t('gift.pollErrorTitle', 'Could not check gift status')}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-dark-400">
|
||||||
|
{t(
|
||||||
|
'gift.pollErrorDesc',
|
||||||
|
'Your purchase was successful. Check your dashboard for details.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="flex w-full items-center justify-center rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
|
||||||
|
>
|
||||||
|
{t('gift.backToDashboard', 'Back to dashboard')}
|
||||||
|
</button>
|
||||||
|
</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('gift.pollTimeout', 'Taking longer than expected')}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-dark-400">
|
||||||
|
{t(
|
||||||
|
'gift.pollTimeoutDesc',
|
||||||
|
'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('gift.retry', 'Retry')}
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoTokenState() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
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={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-dark-50">{t('gift.noToken', 'Invalid link')}</h1>
|
||||||
|
<p className="mt-2 text-sm text-dark-400">
|
||||||
|
{t('gift.noTokenDesc', 'This gift link is invalid or has expired.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/gift')}
|
||||||
|
className="rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
|
||||||
|
>
|
||||||
|
{t('gift.backToGift', 'Go back')}
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Main Component
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export default function GiftResult() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
const mode = searchParams.get('mode');
|
||||||
|
const rawUrlWarning = searchParams.get('warning');
|
||||||
|
const urlWarning = rawUrlWarning && KNOWN_WARNINGS.has(rawUrlWarning) ? rawUrlWarning : null;
|
||||||
|
|
||||||
|
const pollStart = useRef(Date.now());
|
||||||
|
const [pollTimedOut, setPollTimedOut] = useState(false);
|
||||||
|
|
||||||
|
const isBalanceMode = mode === 'balance';
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: status,
|
||||||
|
isError,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['gift-status', token],
|
||||||
|
queryFn: () => giftApi.getPurchaseStatus(token!),
|
||||||
|
enabled: !!token && !pollTimedOut,
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
// Balance mode: fetch once, no polling
|
||||||
|
if (isBalanceMode) return false;
|
||||||
|
|
||||||
|
const s = query.state.data?.status;
|
||||||
|
if (s === 'delivered' || s === 'failed' || s === 'pending_activation' || s === 'expired')
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Check poll timeout
|
||||||
|
if (Date.now() - pollStart.current > MAX_POLL_MS) {
|
||||||
|
setPollTimedOut(true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 3000;
|
||||||
|
},
|
||||||
|
retry: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRetryPoll = useCallback(() => {
|
||||||
|
pollStart.current = Date.now();
|
||||||
|
setPollTimedOut(false);
|
||||||
|
refetch();
|
||||||
|
}, [refetch]);
|
||||||
|
|
||||||
|
// No token
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh items-center justify-center px-4">
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md rounded-2xl border border-dark-800/50 bg-dark-900/50 p-8"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
>
|
||||||
|
<NoTokenState />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDelivered = status?.status === 'delivered';
|
||||||
|
const isPendingActivation = status?.status === 'pending_activation';
|
||||||
|
const isFailed = status?.status === 'failed' || status?.status === 'expired';
|
||||||
|
|
||||||
|
// Warning from status response (persisted on purchase) takes priority over URL param
|
||||||
|
const statusWarning =
|
||||||
|
status?.warning && KNOWN_WARNINGS.has(status.warning) ? status.warning : null;
|
||||||
|
const warning = statusWarning ?? urlWarning;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh items-center justify-center px-4">
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md rounded-2xl border border-dark-800/50 bg-dark-900/50 p-8"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
>
|
||||||
|
{isError ? (
|
||||||
|
<PollErrorState />
|
||||||
|
) : isDelivered ? (
|
||||||
|
<DeliveredState
|
||||||
|
recipientContact={status.recipient_contact_value}
|
||||||
|
tariffName={status.tariff_name}
|
||||||
|
periodDays={status.period_days}
|
||||||
|
giftMessage={status.gift_message}
|
||||||
|
warning={warning}
|
||||||
|
/>
|
||||||
|
) : isPendingActivation ? (
|
||||||
|
<PendingActivationState
|
||||||
|
recipientContact={status.recipient_contact_value}
|
||||||
|
tariffName={status.tariff_name}
|
||||||
|
periodDays={status.period_days}
|
||||||
|
warning={warning}
|
||||||
|
/>
|
||||||
|
) : isFailed ? (
|
||||||
|
<FailedState />
|
||||||
|
) : pollTimedOut ? (
|
||||||
|
<PollTimedOutState onRetry={handleRetryPoll} />
|
||||||
|
) : (
|
||||||
|
<PendingState />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
985
src/pages/GiftSubscription.tsx
Normal file
985
src/pages/GiftSubscription.tsx
Normal file
@@ -0,0 +1,985 @@
|
|||||||
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
|
import { useNavigate, Link } from 'react-router';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { giftApi } from '../api/gift';
|
||||||
|
import type {
|
||||||
|
GiftConfig,
|
||||||
|
GiftTariff,
|
||||||
|
GiftTariffPeriod,
|
||||||
|
GiftPaymentMethod,
|
||||||
|
GiftPurchaseRequest,
|
||||||
|
} from '../api/gift';
|
||||||
|
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
import { getApiErrorMessage } from '../utils/api-error';
|
||||||
|
import { formatPrice } from '../utils/format';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function detectContactType(value: string): 'email' | 'telegram' {
|
||||||
|
return value.startsWith('@') ? 'telegram' : 'email';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidContact(value: string): boolean {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
if (trimmed.startsWith('@')) {
|
||||||
|
return /^@[a-zA-Z][a-zA-Z0-9_]{4,31}$/.test(trimmed);
|
||||||
|
}
|
||||||
|
return /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPeriodLabel(
|
||||||
|
days: number,
|
||||||
|
t: (key: string, options?: Record<string, unknown>) => string,
|
||||||
|
): string {
|
||||||
|
const key = `landing.periodLabels.d${days}`;
|
||||||
|
const result = t(key);
|
||||||
|
if (result !== key) return result;
|
||||||
|
|
||||||
|
const months = Math.floor(days / 30);
|
||||||
|
const remainder = days % 30;
|
||||||
|
if (months > 0 && remainder === 0) {
|
||||||
|
return t('landing.periodLabels.nMonths', { count: months });
|
||||||
|
}
|
||||||
|
return t('landing.periodLabels.nDays', { count: days });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Sub-components
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function LoadingSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-dark-600 border-t-accent-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorState({ message }: { message: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh items-center justify-center px-4">
|
||||||
|
<div className="flex max-w-sm flex-col items-center gap-4 text-center">
|
||||||
|
<div className="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={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-dark-50">{t('gift.failedTitle', 'Error')}</h2>
|
||||||
|
<p className="text-sm text-dark-300">{message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DisabledState() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => navigate('/'), 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh items-center justify-center px-4">
|
||||||
|
<div className="flex max-w-sm flex-col items-center gap-4 text-center">
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-dark-800/50">
|
||||||
|
<svg
|
||||||
|
className="h-8 w-8 text-dark-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-dark-50">
|
||||||
|
{t('gift.featureDisabled', 'Gift subscriptions are currently unavailable')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-dark-300">{t('gift.redirecting', 'Redirecting...')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PeriodTabs({
|
||||||
|
periods,
|
||||||
|
selectedDays,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
periods: GiftTariffPeriod[];
|
||||||
|
selectedDays: number;
|
||||||
|
onSelect: (days: number) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{periods.map((period) => (
|
||||||
|
<button
|
||||||
|
key={period.days}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(period.days)}
|
||||||
|
className={cn(
|
||||||
|
'whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition-all duration-200',
|
||||||
|
selectedDays === period.days
|
||||||
|
? 'bg-accent-500 text-white shadow-lg shadow-accent-500/25'
|
||||||
|
: 'bg-dark-800/50 text-dark-300 hover:bg-dark-700/50 hover:text-dark-100',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatPeriodLabel(period.days, t)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TariffCard({
|
||||||
|
tariff,
|
||||||
|
isSelected,
|
||||||
|
selectedPeriod,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
tariff: GiftTariff;
|
||||||
|
isSelected: boolean;
|
||||||
|
selectedPeriod: GiftTariffPeriod | undefined;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={isSelected}
|
||||||
|
onClick={onSelect}
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full flex-col rounded-2xl border p-5 text-start transition-all duration-200',
|
||||||
|
isSelected
|
||||||
|
? 'border-accent-500/50 bg-accent-500/5 ring-1 ring-accent-500/25'
|
||||||
|
: 'border-dark-800/50 bg-dark-900/50 hover:border-dark-700/50 hover:bg-dark-800/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-3 flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-dark-50">{tariff.name}</h3>
|
||||||
|
{tariff.description && (
|
||||||
|
<p className="mt-0.5 text-xs text-dark-400">{tariff.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors',
|
||||||
|
isSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSelected && (
|
||||||
|
<svg
|
||||||
|
className="h-3 w-3 text-white"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={3}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info row */}
|
||||||
|
<div className="flex items-center gap-3 text-xs text-dark-400">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<svg
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{tariff.traffic_limit_gb === 0 ? '\u221E' : tariff.traffic_limit_gb}{' '}
|
||||||
|
{t('landing.gb', 'GB')}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<svg
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{tariff.device_limit} {t('landing.devices', 'devices')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price */}
|
||||||
|
{selectedPeriod && (
|
||||||
|
<div className="mt-3 border-t border-dark-800/30 pt-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-lg font-bold text-accent-400">
|
||||||
|
{formatPrice(selectedPeriod.price_kopeks)}
|
||||||
|
</span>
|
||||||
|
{selectedPeriod.original_price_kopeks != null &&
|
||||||
|
selectedPeriod.original_price_kopeks > selectedPeriod.price_kopeks && (
|
||||||
|
<>
|
||||||
|
<span className="text-sm text-dark-500 line-through">
|
||||||
|
{formatPrice(selectedPeriod.original_price_kopeks)}
|
||||||
|
</span>
|
||||||
|
{selectedPeriod.discount_percent != null && (
|
||||||
|
<span className="rounded-full bg-accent-500/20 px-1.5 py-0.5 text-[10px] font-bold text-accent-400">
|
||||||
|
-{selectedPeriod.discount_percent}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaymentModeToggle({
|
||||||
|
mode,
|
||||||
|
onToggle,
|
||||||
|
balanceLabel,
|
||||||
|
}: {
|
||||||
|
mode: 'balance' | 'gateway';
|
||||||
|
onToggle: (mode: 'balance' | 'gateway') => void;
|
||||||
|
balanceLabel: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label={t('gift.paymentMode', 'Payment mode')}
|
||||||
|
className="flex rounded-xl bg-dark-800/50 p-1"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggle('balance')}
|
||||||
|
aria-pressed={mode === 'balance'}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 rounded-lg px-4 py-2.5 text-sm font-medium transition-all duration-200',
|
||||||
|
mode === 'balance'
|
||||||
|
? 'bg-dark-700 text-dark-50 shadow-sm'
|
||||||
|
: 'text-dark-400 hover:text-dark-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{balanceLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggle('gateway')}
|
||||||
|
aria-pressed={mode === 'gateway'}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 rounded-lg px-4 py-2.5 text-sm font-medium transition-all duration-200',
|
||||||
|
mode === 'gateway'
|
||||||
|
? 'bg-dark-700 text-dark-50 shadow-sm'
|
||||||
|
: 'text-dark-400 hover:text-dark-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t('gift.viaGateway', 'Via payment gateway')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaymentMethodCard({
|
||||||
|
method,
|
||||||
|
isSelected,
|
||||||
|
selectedSubOption,
|
||||||
|
onSelect,
|
||||||
|
onSelectSubOption,
|
||||||
|
}: {
|
||||||
|
method: GiftPaymentMethod;
|
||||||
|
isSelected: boolean;
|
||||||
|
selectedSubOption: string | null;
|
||||||
|
onSelect: () => void;
|
||||||
|
onSelectSubOption: (subOptionId: string) => void;
|
||||||
|
}) {
|
||||||
|
const hasSubOptions = method.sub_options && method.sub_options.length > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-2xl border transition-all duration-200',
|
||||||
|
isSelected
|
||||||
|
? 'border-accent-500/50 bg-accent-500/5'
|
||||||
|
: 'border-dark-800/50 bg-dark-900/50 hover:border-dark-700/50 hover:bg-dark-800/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={isSelected}
|
||||||
|
onClick={onSelect}
|
||||||
|
className="flex w-full items-center gap-4 p-4 text-start"
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
{method.icon_url && (
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-dark-800/50">
|
||||||
|
<img src={method.icon_url} alt="" className="h-6 w-6 object-contain" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Text */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-dark-100">{method.display_name}</p>
|
||||||
|
{method.description && (
|
||||||
|
<p className="mt-0.5 truncate text-xs text-dark-400">{method.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Radio */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors',
|
||||||
|
isSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSelected && <div className="h-2 w-2 rounded-full bg-white" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Sub-options */}
|
||||||
|
{isSelected && hasSubOptions && (
|
||||||
|
<div className="border-t border-dark-800/30 px-4 pb-4 pt-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{method.sub_options!.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectSubOption(opt.id)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl px-4 py-2 text-sm font-medium transition-all duration-200',
|
||||||
|
selectedSubOption === opt.id
|
||||||
|
? 'bg-accent-500 text-white shadow-sm shadow-accent-500/25'
|
||||||
|
: 'bg-dark-800/50 text-dark-300 hover:bg-dark-700/50 hover:text-dark-100',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecipientSection({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 rounded-2xl border border-dark-800/50 bg-dark-900/50 p-5">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="gift-recipient-input"
|
||||||
|
className="mb-2 block text-sm font-medium text-dark-200"
|
||||||
|
>
|
||||||
|
{t('gift.recipient', 'Recipient')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="gift-recipient-input"
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={t('gift.recipientPlaceholder', 'email@example.com or @telegram')}
|
||||||
|
className="w-full rounded-xl border border-dark-700/50 bg-dark-800/50 px-4 py-3 text-sm text-dark-50 placeholder-dark-500 outline-none transition-colors focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/25"
|
||||||
|
/>
|
||||||
|
<p className="mt-1.5 text-xs text-dark-500">
|
||||||
|
{t(
|
||||||
|
'gift.recipientHint',
|
||||||
|
'Enter the email or Telegram username of the person you want to gift',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GiftMessageSection({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="rounded-2xl border border-dark-800/50 bg-dark-900/50 p-5">
|
||||||
|
<label
|
||||||
|
htmlFor="gift-message-input"
|
||||||
|
className="mb-2 block text-sm font-medium text-dark-200"
|
||||||
|
>
|
||||||
|
{t('gift.giftMessage', 'Personal message')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="gift-message-input"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value.length <= 1000) {
|
||||||
|
onChange(e.target.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t(
|
||||||
|
'gift.giftMessagePlaceholder',
|
||||||
|
'Add a personal message for the recipient (optional)',
|
||||||
|
)}
|
||||||
|
rows={3}
|
||||||
|
maxLength={1000}
|
||||||
|
className="w-full resize-none rounded-xl border border-dark-700/50 bg-dark-800/50 px-4 py-3 text-sm text-dark-50 placeholder-dark-500 outline-none transition-colors focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/25"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-right text-xs text-dark-500">{value.length}/1000</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GiftSummaryCard({
|
||||||
|
config,
|
||||||
|
selectedTariff,
|
||||||
|
selectedPeriod,
|
||||||
|
currentPrice,
|
||||||
|
paymentMode,
|
||||||
|
isSubmitting,
|
||||||
|
canSubmit,
|
||||||
|
submitError,
|
||||||
|
insufficientBalance,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
config: GiftConfig;
|
||||||
|
selectedTariff: GiftTariff | undefined;
|
||||||
|
selectedPeriod: GiftTariffPeriod | undefined;
|
||||||
|
currentPrice: number;
|
||||||
|
paymentMode: 'balance' | 'gateway';
|
||||||
|
isSubmitting: boolean;
|
||||||
|
canSubmit: boolean;
|
||||||
|
submitError: string | null;
|
||||||
|
insufficientBalance: boolean;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="rounded-2xl border border-dark-800/50 bg-dark-900/50 p-5">
|
||||||
|
{selectedTariff && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wider text-dark-500">
|
||||||
|
{t('gift.tariff', 'Tariff')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold text-dark-50">{selectedTariff.name}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedPeriod && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wider text-dark-500">
|
||||||
|
{t('gift.period', 'Period')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-dark-200">
|
||||||
|
{formatPeriodLabel(selectedPeriod.days, t)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="border-t border-dark-800/50 pt-4">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wider text-dark-500">
|
||||||
|
{t('gift.total', 'Total')}
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<span className="text-2xl font-bold text-accent-400">{formatPrice(currentPrice)}</span>
|
||||||
|
{selectedPeriod?.original_price_kopeks != null &&
|
||||||
|
selectedPeriod.original_price_kopeks > selectedPeriod.price_kopeks && (
|
||||||
|
<>
|
||||||
|
<span className="text-base text-dark-500 line-through">
|
||||||
|
{formatPrice(selectedPeriod.original_price_kopeks)}
|
||||||
|
</span>
|
||||||
|
{selectedPeriod.discount_percent != null && (
|
||||||
|
<span className="rounded-full bg-accent-500/20 px-2 py-0.5 text-xs font-bold text-accent-400">
|
||||||
|
-{selectedPeriod.discount_percent}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Balance info for balance mode */}
|
||||||
|
{paymentMode === 'balance' && (
|
||||||
|
<div className="mt-4 border-t border-dark-800/50 pt-4">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wider text-dark-500">
|
||||||
|
{t('gift.yourBalance', 'Your balance')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold text-dark-200">
|
||||||
|
{formatPrice(config.balance_kopeks)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Insufficient balance warning */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{insufficientBalance && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -8 }}
|
||||||
|
className="rounded-xl border border-warning-500/20 bg-warning-500/5 p-3"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-warning-400">
|
||||||
|
{t('gift.insufficientBalance', 'Insufficient balance.')}{' '}
|
||||||
|
<Link
|
||||||
|
to="/balance"
|
||||||
|
className="font-medium text-accent-400 underline underline-offset-2"
|
||||||
|
>
|
||||||
|
{t('gift.topUpBalance', 'Top up balance')}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{submitError && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -8 }}
|
||||||
|
className="rounded-xl border border-error-500/20 bg-error-500/5 p-3"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-error-400">{submitError}</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Gift button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={!canSubmit || isSubmitting}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center justify-center gap-2 rounded-2xl px-6 py-4 text-base font-semibold transition-all duration-200',
|
||||||
|
canSubmit && !isSubmitting
|
||||||
|
? 'bg-accent-500 text-white shadow-lg shadow-accent-500/25 hover:bg-accent-400 hover:shadow-accent-500/40 active:scale-[0.98]'
|
||||||
|
: 'cursor-not-allowed bg-dark-800 text-dark-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{t('gift.giftButton', 'Gift')} {formatPrice(currentPrice)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Main Component
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export default function GiftSubscription() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Fetch config
|
||||||
|
const {
|
||||||
|
data: config,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['gift-config'],
|
||||||
|
queryFn: giftApi.getConfig,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Selection state
|
||||||
|
const [selectedTariffId, setSelectedTariffId] = useState<number | null>(null);
|
||||||
|
const [selectedPeriodDays, setSelectedPeriodDays] = useState<number | null>(null);
|
||||||
|
const [recipientValue, setRecipientValue] = useState('');
|
||||||
|
const [giftMessage, setGiftMessage] = useState('');
|
||||||
|
const [paymentMode, setPaymentMode] = useState<'balance' | 'gateway'>('balance');
|
||||||
|
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
|
||||||
|
const [selectedSubOption, setSelectedSubOption] = useState<string | null>(null);
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Collect ALL unique periods across ALL tariffs
|
||||||
|
const allPeriods = useMemo(() => {
|
||||||
|
if (!config) return [];
|
||||||
|
const periodMap = new Map<number, GiftTariffPeriod>();
|
||||||
|
for (const tariff of config.tariffs) {
|
||||||
|
for (const period of tariff.periods) {
|
||||||
|
if (!periodMap.has(period.days)) {
|
||||||
|
periodMap.set(period.days, period);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(periodMap.values()).sort((a, b) => a.days - b.days);
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
// Filter tariffs to only those that have the selected period
|
||||||
|
const visibleTariffs = useMemo(() => {
|
||||||
|
if (!config || !selectedPeriodDays) return config?.tariffs ?? [];
|
||||||
|
return config.tariffs.filter((tariff) =>
|
||||||
|
tariff.periods.some((p) => p.days === selectedPeriodDays),
|
||||||
|
);
|
||||||
|
}, [config, selectedPeriodDays]);
|
||||||
|
|
||||||
|
// Auto-select first tariff, period, method on config load
|
||||||
|
useEffect(() => {
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
if (allPeriods.length > 0 && selectedPeriodDays === null) {
|
||||||
|
setSelectedPeriodDays(allPeriods[0].days);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visibleTariffs.length > 0 && selectedTariffId === null) {
|
||||||
|
setSelectedTariffId(visibleTariffs[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.payment_methods.length > 0 && selectedMethod === null) {
|
||||||
|
const firstMethod = config.payment_methods[0];
|
||||||
|
setSelectedMethod(firstMethod.method_id);
|
||||||
|
if (firstMethod.sub_options && firstMethod.sub_options.length >= 1) {
|
||||||
|
setSelectedSubOption(firstMethod.sub_options[0].id);
|
||||||
|
} else {
|
||||||
|
setSelectedSubOption(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [config, allPeriods, visibleTariffs, selectedTariffId, selectedPeriodDays, selectedMethod]);
|
||||||
|
|
||||||
|
// When period changes, auto-select first visible tariff if current is hidden
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visibleTariffs.length) return;
|
||||||
|
const currentVisible = visibleTariffs.find((tariff) => tariff.id === selectedTariffId);
|
||||||
|
if (!currentVisible) {
|
||||||
|
setSelectedTariffId(visibleTariffs[0].id);
|
||||||
|
}
|
||||||
|
}, [visibleTariffs, selectedTariffId]);
|
||||||
|
|
||||||
|
// Derived data
|
||||||
|
const selectedTariff = useMemo(
|
||||||
|
() => config?.tariffs.find((tr) => tr.id === selectedTariffId),
|
||||||
|
[config?.tariffs, selectedTariffId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedPeriod = useMemo(
|
||||||
|
() => selectedTariff?.periods.find((p) => p.days === selectedPeriodDays),
|
||||||
|
[selectedTariff, selectedPeriodDays],
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentPrice = selectedPeriod?.price_kopeks ?? 0;
|
||||||
|
|
||||||
|
const insufficientBalance =
|
||||||
|
paymentMode === 'balance' && config != null && config.balance_kopeks < currentPrice;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
const canSubmit = useMemo(() => {
|
||||||
|
if (!selectedTariffId || !selectedPeriodDays) return false;
|
||||||
|
if (!isValidContact(recipientValue)) return false;
|
||||||
|
if (paymentMode === 'gateway' && !selectedMethod) return false;
|
||||||
|
if (insufficientBalance) return false;
|
||||||
|
return true;
|
||||||
|
}, [
|
||||||
|
selectedTariffId,
|
||||||
|
selectedPeriodDays,
|
||||||
|
recipientValue,
|
||||||
|
paymentMode,
|
||||||
|
selectedMethod,
|
||||||
|
insufficientBalance,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Purchase mutation
|
||||||
|
const purchaseMutation = useMutation({
|
||||||
|
mutationFn: (data: GiftPurchaseRequest) => giftApi.createPurchase(data),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
if (result.payment_url) {
|
||||||
|
window.location.href = result.payment_url;
|
||||||
|
} else {
|
||||||
|
// Balance mode - show success
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['balance'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['gift-config'] });
|
||||||
|
const params = new URLSearchParams({ token: result.purchase_token, mode: 'balance' });
|
||||||
|
if (result.warning) {
|
||||||
|
params.set('warning', result.warning);
|
||||||
|
}
|
||||||
|
navigate('/gift/result?' + params.toString());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
const msg = getApiErrorMessage(
|
||||||
|
err,
|
||||||
|
t('gift.failedDesc', 'Something went wrong. Please try again.'),
|
||||||
|
);
|
||||||
|
setSubmitError(msg);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Submit handler
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!selectedTariffId || !selectedPeriodDays || !canSubmit || purchaseMutation.isPending)
|
||||||
|
return;
|
||||||
|
|
||||||
|
setSubmitError(null);
|
||||||
|
|
||||||
|
let paymentMethod: string | undefined;
|
||||||
|
if (paymentMode === 'gateway' && selectedMethod) {
|
||||||
|
paymentMethod = selectedMethod;
|
||||||
|
if (selectedSubOption) {
|
||||||
|
paymentMethod = `${paymentMethod}_${selectedSubOption}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: GiftPurchaseRequest = {
|
||||||
|
tariff_id: selectedTariffId,
|
||||||
|
period_days: selectedPeriodDays,
|
||||||
|
recipient_type: detectContactType(recipientValue),
|
||||||
|
recipient_value: recipientValue.trim(),
|
||||||
|
gift_message: giftMessage.trim() || undefined,
|
||||||
|
payment_mode: paymentMode,
|
||||||
|
payment_method: paymentMethod,
|
||||||
|
};
|
||||||
|
|
||||||
|
purchaseMutation.mutate(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Balance label with current amount
|
||||||
|
const balanceLabel = useMemo(() => {
|
||||||
|
if (!config) return t('gift.fromBalance', 'From balance');
|
||||||
|
return `${t('gift.fromBalance', 'From balance')} (${formatPrice(config.balance_kopeks)})`;
|
||||||
|
}, [config, t]);
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isLoading) {
|
||||||
|
return <LoadingSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error || !config) {
|
||||||
|
const errMsg = getApiErrorMessage(error, t('gift.notFound', 'Gift configuration not found'));
|
||||||
|
return <ErrorState message={errMsg} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disabled state
|
||||||
|
if (!config.is_enabled) {
|
||||||
|
return <DisabledState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const showTariffCards = visibleTariffs.length > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-dvh">
|
||||||
|
<div className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
|
||||||
|
{/* Header */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
className="mb-10 text-center"
|
||||||
|
>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-dark-50 sm:text-4xl">
|
||||||
|
{t('gift.title', 'Gift Subscription')}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 text-base text-dark-300 sm:text-lg">
|
||||||
|
{t('gift.subtitle', 'Give the gift of secure internet to someone special')}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Two-column layout */}
|
||||||
|
<div className="grid gap-8 lg:grid-cols-[1fr_380px]">
|
||||||
|
{/* Left column */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.1 }}
|
||||||
|
className="min-w-0 space-y-6"
|
||||||
|
>
|
||||||
|
{/* Period tabs */}
|
||||||
|
{allPeriods.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-3 text-sm font-medium uppercase tracking-wider text-dark-400">
|
||||||
|
{t('gift.choosePeriod', 'Choose period')}
|
||||||
|
</h2>
|
||||||
|
<PeriodTabs
|
||||||
|
periods={allPeriods}
|
||||||
|
selectedDays={selectedPeriodDays ?? 0}
|
||||||
|
onSelect={setSelectedPeriodDays}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tariff cards */}
|
||||||
|
{showTariffCards && (
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-3 text-sm font-medium uppercase tracking-wider text-dark-400">
|
||||||
|
{t('gift.chooseTariff', 'Choose tariff')}
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={t('gift.chooseTariff', 'Choose tariff')}
|
||||||
|
className="grid gap-3 sm:grid-cols-2"
|
||||||
|
>
|
||||||
|
{visibleTariffs.map((tariff) => {
|
||||||
|
const period = tariff.periods.find((p) => p.days === selectedPeriodDays);
|
||||||
|
return (
|
||||||
|
<TariffCard
|
||||||
|
key={tariff.id}
|
||||||
|
tariff={tariff}
|
||||||
|
isSelected={tariff.id === selectedTariffId}
|
||||||
|
selectedPeriod={period}
|
||||||
|
onSelect={() => setSelectedTariffId(tariff.id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recipient */}
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-3 text-sm font-medium uppercase tracking-wider text-dark-400">
|
||||||
|
{t('gift.recipientLabel', 'Recipient')}
|
||||||
|
</h2>
|
||||||
|
<RecipientSection
|
||||||
|
value={recipientValue}
|
||||||
|
onChange={(v) => {
|
||||||
|
setRecipientValue(v);
|
||||||
|
setSubmitError(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gift message */}
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-3 text-sm font-medium uppercase tracking-wider text-dark-400">
|
||||||
|
{t('gift.giftMessageLabel', 'Message')}
|
||||||
|
</h2>
|
||||||
|
<GiftMessageSection value={giftMessage} onChange={setGiftMessage} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment mode toggle */}
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-3 text-sm font-medium uppercase tracking-wider text-dark-400">
|
||||||
|
{t('gift.paymentMode', 'Payment method')}
|
||||||
|
</h2>
|
||||||
|
<PaymentModeToggle
|
||||||
|
mode={paymentMode}
|
||||||
|
onToggle={setPaymentMode}
|
||||||
|
balanceLabel={balanceLabel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment method cards (gateway mode only) */}
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{paymentMode === 'gateway' && config.payment_methods.length > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={t('gift.paymentMethod', 'Payment method')}
|
||||||
|
className="space-y-2"
|
||||||
|
>
|
||||||
|
{config.payment_methods.map((method) => (
|
||||||
|
<PaymentMethodCard
|
||||||
|
key={method.method_id}
|
||||||
|
method={method}
|
||||||
|
isSelected={method.method_id === selectedMethod}
|
||||||
|
selectedSubOption={
|
||||||
|
method.method_id === selectedMethod ? selectedSubOption : null
|
||||||
|
}
|
||||||
|
onSelect={() => {
|
||||||
|
setSelectedMethod(method.method_id);
|
||||||
|
if (method.sub_options && method.sub_options.length >= 1) {
|
||||||
|
setSelectedSubOption(method.sub_options[0].id);
|
||||||
|
} else {
|
||||||
|
setSelectedSubOption(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSelectSubOption={setSelectedSubOption}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Right column (sticky sidebar / bottom on mobile) */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.2 }}
|
||||||
|
className="min-w-0 lg:sticky lg:top-8 lg:self-start"
|
||||||
|
>
|
||||||
|
<GiftSummaryCard
|
||||||
|
config={config}
|
||||||
|
selectedTariff={selectedTariff}
|
||||||
|
selectedPeriod={selectedPeriod}
|
||||||
|
currentPrice={currentPrice}
|
||||||
|
paymentMode={paymentMode}
|
||||||
|
isSubmitting={purchaseMutation.isPending}
|
||||||
|
canSubmit={canSubmit}
|
||||||
|
submitError={submitError}
|
||||||
|
insufficientBalance={insufficientBalance}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -203,11 +203,11 @@ export function createWebAdapter(): PlatformContext {
|
|||||||
},
|
},
|
||||||
|
|
||||||
openLink(url: string, _options?: { tryInstantView?: boolean }) {
|
openLink(url: string, _options?: { tryInstantView?: boolean }) {
|
||||||
window.open(url, '_blank', 'noopener,noreferrer');
|
window.open(url, '_blank', 'noopener');
|
||||||
},
|
},
|
||||||
|
|
||||||
openTelegramLink(url: string) {
|
openTelegramLink(url: string) {
|
||||||
window.open(url, '_blank', 'noopener,noreferrer');
|
window.open(url, '_blank', 'noopener');
|
||||||
},
|
},
|
||||||
|
|
||||||
async share(text: string, url?: string): Promise<boolean> {
|
async share(text: string, url?: string): Promise<boolean> {
|
||||||
|
|||||||
Reference in New Issue
Block a user