Merge pull request #267 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-03-09 23:41:22 +03:00
committed by GitHub
26 changed files with 3359 additions and 104 deletions

View File

@@ -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={

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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
View 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
View 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);
},
};

View File

@@ -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>

View 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>
);
}

View File

@@ -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

View 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">
&ldquo;{gift.gift_message}&rdquo;
</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>
);
}

View File

@@ -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 },
]; ];

View File

@@ -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,14 +342,29 @@ 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">
{/* Left fade */}
<div
className={cn(
'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',
navCanScrollLeft ? 'opacity-100' : 'opacity-0',
)}
/>
{/* Right fade */}
<div
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',
)}
/>
<nav ref={navRef} className="scrollbar-hide flex items-center gap-1 overflow-x-auto">
{desktopNavItems.map((item) => ( {desktopNavItems.map((item) => (
<Link <Link
key={item.path} key={item.path}
to={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',
isActive(item.path) isActive(item.path)
? 'bg-dark-800 text-dark-50' ? 'bg-dark-800 text-dark-50'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200', : 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
@@ -339,7 +379,7 @@ export function AppShell({ children }: AppShellProps) {
to="/referral" to="/referral"
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',
isActive('/referral') isActive('/referral')
? 'bg-dark-800 text-dark-50' ? 'bg-dark-800 text-dark-50'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200', : 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
@@ -349,15 +389,30 @@ export function AppShell({ children }: AppShellProps) {
<span>{t('nav.referral')}</span> <span>{t('nav.referral')}</span>
</Link> </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 && ( {isAdmin && (
<> <>
{/* Separator before admin */} {/* Separator before admin */}
<div className="mx-2 h-5 w-px bg-dark-700" /> <div className="mx-2 h-5 w-px shrink-0 bg-dark-700" />
<Link <Link
to="/admin" to="/admin"
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') location.pathname.startsWith('/admin')
? 'bg-warning-500/10 text-warning-400' ? 'bg-warning-500/10 text-warning-400'
: 'text-warning-500/70 hover:bg-warning-500/10 hover:text-warning-400', : 'text-warning-500/70 hover:bg-warning-500/10 hover:text-warning-400',
@@ -369,6 +424,7 @@ export function AppShell({ children }: AppShellProps) {
</> </>
)} )}
</nav> </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 */}

View File

@@ -16,6 +16,7 @@ export {
InfoIcon, InfoIcon,
CogIcon, CogIcon,
WheelIcon, WheelIcon,
GiftIcon,
SearchIcon, SearchIcon,
PlusIcon, PlusIcon,
ArrowRightIcon, ArrowRightIcon,

View File

@@ -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,
}; };
} }

View File

@@ -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."
}
} }
} }

View File

@@ -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": "نام کاربری تلگرام قابل تأیید نبود. ممکن است گیرنده اعلان هدیه را دریافت نکند."
}
} }
} }

View File

@@ -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-юзернейм. Получатель может не получить уведомление о подарке."
}
} }
} }

View File

@@ -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": "请为所有自定义按钮提供有效的URLhttp:// 或 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 用户名。收件人可能不会收到礼物通知。"
}
} }
} }

View File

@@ -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')}

View File

@@ -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 */}

View File

@@ -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

View File

@@ -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,18 +155,15 @@ 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) {
if (mountedRef.current) {
showToast({ showToast({
type: 'error', type: 'error',
message: getErrorDetail(err) || t('profile.accounts.linkError'), message: getErrorDetail(err) || t('profile.accounts.linkError'),
}); });
} }
}
}; };
const script = document.createElement('script'); const script = document.createElement('script');
@@ -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" />;
} }

View File

@@ -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
View 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">&ldquo;{giftMessage}&rdquo;</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>
);
}

View 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>
);
}

View File

@@ -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> {