diff --git a/src/App.tsx b/src/App.tsx index 909a8c2..f0c973f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -36,6 +36,8 @@ const Contests = lazy(() => import('./pages/Contests')); const Polls = lazy(() => import('./pages/Polls')); const Info = lazy(() => import('./pages/Info')); 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 ConnectionQR = lazy(() => import('./pages/ConnectionQR')); const QuickPurchase = lazy(() => import('./pages/QuickPurchase')); @@ -420,6 +422,30 @@ function App() { } /> + + + + + + + + } + /> + + + + + + + + } + /> => { + try { + const response = await apiClient.get('/cabinet/branding/gift-enabled'); + return response.data; + } catch { + return { enabled: false }; + } + }, + + // Update gift enabled (admin only) + updateGiftEnabled: async (enabled: boolean): Promise => { + const response = await apiClient.patch('/cabinet/branding/gift-enabled', { + enabled, + }); + return response.data; + }, + // Get analytics counters (public, no auth required) getAnalyticsCounters: async (): Promise => { try { diff --git a/src/api/client.ts b/src/api/client.ts index 4e38a3d..e433589 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -92,18 +92,22 @@ apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) => if (!isAuthEndpoint(config.url)) { let token = tokenStorage.getAccessToken(); - // Проверяем срок действия токена перед запросом if (token && isTokenExpired(token)) { - // Используем централизованный менеджер для refresh + // Access token expired — try refresh const newToken = await tokenRefreshManager.refreshAccessToken(); if (newToken) { token = newToken; } else { - // Refresh не удался - редирект на логин tokenStorage.clearTokens(); safeRedirectToLogin(); 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) { diff --git a/src/api/gift.ts b/src/api/gift.ts new file mode 100644 index 0000000..c96c180 --- /dev/null +++ b/src/api/gift.ts @@ -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 => { + const { data } = await apiClient.get('/cabinet/gift/config'); + return data; + }, + + createPurchase: async (request: GiftPurchaseRequest): Promise => { + const { data } = await apiClient.post('/cabinet/gift/purchase', request); + return data; + }, + + getPurchaseStatus: async (token: string): Promise => { + const { data } = await apiClient.get(`/cabinet/gift/purchase/${token}`); + return data; + }, + + getPendingGifts: async (): Promise => { + const { data } = await apiClient.get('/cabinet/gift/pending'); + return data; + }, +}; diff --git a/src/api/menuLayout.ts b/src/api/menuLayout.ts new file mode 100644 index 0000000..148367f --- /dev/null +++ b/src/api/menuLayout.ts @@ -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; + 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 = { + 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 => { + const response = await apiClient.get('/cabinet/admin/menu-layout'); + return normalizeConfig(response.data); + }, + + updateConfig: async (config: MenuConfig): Promise => { + const response = await apiClient.put('/cabinet/admin/menu-layout', config); + return normalizeConfig(response.data); + }, + + resetConfig: async (): Promise => { + const response = await apiClient.post('/cabinet/admin/menu-layout/reset'); + return normalizeConfig(response.data); + }, +}; diff --git a/src/components/admin/BrandingTab.tsx b/src/components/admin/BrandingTab.tsx index 5a3c620..4bab96c 100644 --- a/src/components/admin/BrandingTab.tsx +++ b/src/components/admin/BrandingTab.tsx @@ -35,6 +35,11 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) { queryFn: brandingApi.getEmailAuthEnabled, }); + const { data: giftSettings } = useQuery({ + queryKey: ['gift-enabled'], + queryFn: brandingApi.getGiftEnabled, + }); + // Mutations const updateBrandingMutation = useMutation({ 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) => { const file = e.target.files?.[0]; if (file) { @@ -225,6 +237,18 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) { disabled={updateEmailAuthMutation.isPending} /> + +
+
+ {t('admin.settings.giftEnabled')} +

{t('admin.settings.giftEnabledDesc')}

+
+ updateGiftMutation.mutate(!(giftSettings?.enabled ?? false))} + disabled={updateGiftMutation.isPending} + /> +
diff --git a/src/components/admin/MenuEditorTab.tsx b/src/components/admin/MenuEditorTab.tsx new file mode 100644 index 0000000..0daa1dc --- /dev/null +++ b/src/components/admin/MenuEditorTab.tsx @@ -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 = () => ( + + + +); + +const TrashIcon = () => ( + + + +); + +const PlusIcon = () => ( + + + +); + +const ChevronIcon = ({ expanded }: { expanded: boolean }) => ( + + + +); + +const LinkIcon = () => ( + + + +); + +const ArrowUpIcon = () => ( + + + +); + +const ArrowDownIcon = () => ( + + + +); + +// ============ 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 ( +
+ {[1, 2, 3].map((n) => ( + + ))} +
+ ); +} + +// ============ ButtonChip ============ + +interface ButtonChipProps { + button: MenuButtonConfig; + isExpanded: boolean; + onToggleExpand: () => void; + onUpdate: (updates: Partial) => 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 ( +
+ {/* Collapsed header */} +
+
+ + +
+ + + {displayName} + + {!isBuiltin && ( + + + + )} + onUpdate({ enabled: !button.enabled })} /> + + {!isBuiltin && ( + + )} +
+ + {/* Expanded body */} + {isExpanded && ( +
+ {/* Color selector */} +
+ +
+ {STYLE_OPTIONS.map((opt) => ( + + ))} +
+
+ + {/* Emoji ID */} +
+ + 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" + /> +
+ + {/* URL input + open mode (custom buttons only) */} + {!isBuiltin && ( + <> +
+ + 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" + /> +
+
+ +
+ {(['external', 'webapp'] as const).map((mode) => ( + + ))} +
+
+ + )} + + {/* Localized labels */} +
+ +
+ {BOT_LOCALES.map((locale) => ( +
+ + {locale} + + + 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" + /> +
+ ))} +

{t('admin.menuEditor.customLabelsHint')}

+
+
+
+ )} +
+ ); +} + +// ============ SortableRow ============ + +interface SortableRowProps { + row: MenuRowConfig; + rowIndex: number; + expandedButtons: Set; + usedBuiltinIds: Set; + onToggleExpand: (buttonId: string) => void; + onUpdateRow: (rowId: string, updates: Partial) => void; + onRemoveRow: (rowId: string) => void; + onUpdateButton: (rowId: string, buttonId: string, updates: Partial) => 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 ( +
+ {/* Row header */} +
+ + + {t('admin.menuEditor.row')} {rowIndex + 1} + +
+ onUpdateRow(row.id, { max_per_row: value })} + /> + {!allBuiltin && ( + + )} +
+ + {/* Row body */} +
+ {row.buttons.map((button, btnIndex) => ( + 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 ============ + +interface InlineAddPanelProps { + rowId: string; + usedBuiltinIds: Set; + 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 ( + + ); + } + + return ( +
+ {availableBuiltins.length > 0 && ( + <> +

+ {t('admin.menuEditor.builtinButtons')} +

+ {availableBuiltins.map((id) => ( + + ))} +
+ + )} + + +
+ ); +} + +// ============ 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(DEFAULT_CONFIG); + const [expandedButtons, setExpandedButtons] = useState>(new Set()); + const savedConfigRef = useRef(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) => { + 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) => { + 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 ( +
+ + + + + {t('common.loading')} +
+ ); + } + + if (isError) { + return ( +
+ {t('common.error')} +
+ ); + } + + return ( +
+ {/* Drag hint */} +
+ + {t('admin.menuEditor.dragHint')} +
+ + {/* Rows */} + + r.id)} + strategy={verticalListSortingStrategy} + > +
+ {draftConfig.rows.map((row, index) => ( + + ))} +
+
+
+ + {/* Add row */} + + + {/* Save / Cancel */} + {hasUnsavedChanges && ( +
+ + +
+ )} + + {/* Reset */} +
+ +
+
+ ); +} diff --git a/src/components/blocking/ChannelSubscriptionScreen.tsx b/src/components/blocking/ChannelSubscriptionScreen.tsx index 88a2cfe..0f77094 100644 --- a/src/components/blocking/ChannelSubscriptionScreen.tsx +++ b/src/components/blocking/ChannelSubscriptionScreen.tsx @@ -10,7 +10,7 @@ function safeOpenUrl(url: string | undefined | null): void { try { const parsed = new URL(url); if (parsed.protocol === 'https:' || parsed.protocol === 'http:') { - window.open(url, '_blank', 'noopener,noreferrer'); + window.open(url, '_blank', 'noopener'); } } catch { // invalid URL, do nothing diff --git a/src/components/dashboard/PendingGiftCard.tsx b/src/components/dashboard/PendingGiftCard.tsx new file mode 100644 index 0000000..85240ad --- /dev/null +++ b/src/components/dashboard/PendingGiftCard.tsx @@ -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 ( +
+ {gifts.map((gift) => ( + + {/* Subtle glow effect */} +
+ +
+ {/* Gift icon */} +
+ + + +
+ + {/* Content */} +
+

{t('gift.pending.title')}

+

+ {gift.tariff_name && ( + + {gift.tariff_name} — {gift.period_days} {t('gift.days')} + + )} + {gift.sender_display && ( + + {t('gift.pending.from', { sender: gift.sender_display })} + + )} +

+ {gift.gift_message && ( +

+ “{gift.gift_message}” +

+ )} +
+ + {/* Activate button */} + + {t('gift.pending.activate')} + +
+ + ))} +
+ ); +} diff --git a/src/components/layout/AppShell/AppHeader.tsx b/src/components/layout/AppShell/AppHeader.tsx index f47fe3c..8299419 100644 --- a/src/components/layout/AppShell/AppHeader.tsx +++ b/src/components/layout/AppShell/AppHeader.tsx @@ -35,6 +35,7 @@ import { InfoIcon, CogIcon, WheelIcon, + GiftIcon, MenuIcon, CloseIcon, SunIcon, @@ -60,6 +61,7 @@ interface AppHeaderProps { referralEnabled?: boolean; hasContests?: boolean; hasPolls?: boolean; + giftEnabled?: boolean; } export function AppHeader({ @@ -75,6 +77,7 @@ export function AppHeader({ referralEnabled, hasContests, hasPolls, + giftEnabled, }: AppHeaderProps) { const { t } = useTranslation(); const location = useLocation(); @@ -164,6 +167,7 @@ export function AppHeader({ ...(hasContests ? [{ path: '/contests', label: t('nav.contests'), icon: GamepadIcon }] : []), ...(hasPolls ? [{ path: '/polls', label: t('nav.polls'), icon: ClipboardIcon }] : []), ...(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 }, ]; diff --git a/src/components/layout/AppShell/AppShell.tsx b/src/components/layout/AppShell/AppShell.tsx index 4752ed0..fbc10f8 100644 --- a/src/components/layout/AppShell/AppShell.tsx +++ b/src/components/layout/AppShell/AppShell.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useLocation, Link } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -20,7 +20,7 @@ import CampaignBonusNotifier from '@/components/CampaignBonusNotifier'; import SuccessNotificationModal from '@/components/SuccessNotificationModal'; import LanguageSwitcher from '@/components/LanguageSwitcher'; import TicketNotificationBell from '@/components/TicketNotificationBell'; -import { SubscriptionIcon } from '@/components/icons'; +import { SubscriptionIcon, GiftIcon } from '@/components/icons'; import { MobileBottomNav } from './MobileBottomNav'; import { AppHeader } from './AppHeader'; @@ -203,7 +203,7 @@ export function AppShell({ children }: AppShellProps) { // Extracted hooks const { appName, logoLetter, hasCustomLogo, logoUrl } = useBranding(); - const { referralEnabled, wheelEnabled, hasContests, hasPolls } = useFeatureFlags(); + const { referralEnabled, wheelEnabled, hasContests, hasPolls, giftEnabled } = useFeatureFlags(); useScrollRestoration(); // Theme toggle visibility @@ -269,6 +269,31 @@ export function AppShell({ children }: AppShellProps) { haptic.impact('light'); }; + // Desktop nav scroll fade indicators + const navRef = useRef(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) // 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) @@ -317,58 +342,89 @@ export function AppShell({ children }: AppShellProps) { {/* Center Navigation */} - +
{/* Right side actions */}
@@ -415,6 +471,7 @@ export function AppShell({ children }: AppShellProps) { referralEnabled={referralEnabled} hasContests={hasContests} hasPolls={hasPolls} + giftEnabled={giftEnabled} /> {/* Desktop spacer */} diff --git a/src/components/layout/AppShell/icons.tsx b/src/components/layout/AppShell/icons.tsx index d8678c9..01c09b2 100644 --- a/src/components/layout/AppShell/icons.tsx +++ b/src/components/layout/AppShell/icons.tsx @@ -16,6 +16,7 @@ export { InfoIcon, CogIcon, WheelIcon, + GiftIcon, SearchIcon, PlusIcon, ArrowRightIcon, diff --git a/src/hooks/useFeatureFlags.ts b/src/hooks/useFeatureFlags.ts index 356d357..8da43e4 100644 --- a/src/hooks/useFeatureFlags.ts +++ b/src/hooks/useFeatureFlags.ts @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { useAuthStore } from '@/store/auth'; +import { brandingApi } from '@/api/branding'; import { referralApi } from '@/api/referral'; import { wheelApi } from '@/api/wheel'; import { contestsApi } from '@/api/contests'; @@ -40,10 +41,19 @@ export function useFeatureFlags() { retry: false, }); + const { data: giftConfig } = useQuery({ + queryKey: ['gift-enabled'], + queryFn: brandingApi.getGiftEnabled, + enabled: isAuthenticated, + staleTime: 60000, + retry: false, + }); + return { referralEnabled: referralTerms?.is_enabled, wheelEnabled: wheelConfig?.is_enabled, hasContests: (contestsCount?.count ?? 0) > 0, hasPolls: (pollsCount?.count ?? 0) > 0, + giftEnabled: giftConfig?.enabled, }; } diff --git a/src/locales/en.json b/src/locales/en.json index 094646a..247732c 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -44,7 +44,8 @@ "polls": "Polls", "info": "Info", "wheel": "Fortune Wheel", - "menu": "Menu" + "menu": "Menu", + "gift": "Gift" }, "notifications": { "ticketNotifications": "Ticket Notifications", @@ -1695,6 +1696,8 @@ "darkTheme": "Dark theme", "emailAuth": "Email auth", "emailAuthDesc": "Allow login via email", + "giftEnabled": "Gift subscription", + "giftEnabledDesc": "Allow users to gift a subscription to another user", "favoritesEmpty": "No favorites yet", "favoritesHint": "Star settings to add them here", "interfaceOptions": "Interface options", @@ -1745,7 +1748,8 @@ "referral": "Referrals", "support": "Support", "info": "Info", - "admin": "Admin Panel" + "admin": "Admin Panel", + "language": "Language" }, "descriptions": { "home": "Personal cabinet button", @@ -1754,7 +1758,8 @@ "referral": "Referral program", "support": "Support tickets", "info": "Information section", - "admin": "Web admin panel" + "admin": "Web admin panel", + "language": "Language selection" }, "styles": { "default": "No color", @@ -1763,6 +1768,26 @@ "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": { "title": "App Management", "addApp": "Add App", @@ -2524,6 +2549,7 @@ "form": { "name": "Group name", "namePlaceholder": "VIP customers", + "nameRequired": "Enter group name", "categoryDiscounts": "Category discounts", "periodDiscounts": "Period discounts", "add": "Add", @@ -2535,6 +2561,7 @@ "rub": "rub.", "autoAssignHint": "0 = don't auto-assign", "applyToAddons": "Apply to additional services", + "isDefault": "Default group", "cancel": "Cancel", "saving": "Saving...", "save": "Save" @@ -3293,7 +3320,7 @@ "status_failed": "Failed", "status_expired": "Expired", "noPurchases": "No purchases", - "showing": "Showing {{from}}\u2013{{to}} of {{total}}", + "showing": "Showing {{from}}–{{to}} of {{total}}", "page": "Page {{current}} of {{total}}", "prev": "Previous", "next": "Next" @@ -4114,5 +4141,63 @@ "nMonths_one": "{{count}} month", "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." + } } } diff --git a/src/locales/fa.json b/src/locales/fa.json index 93f9501..de94769 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -44,7 +44,8 @@ "polls": "نظرسنجی", "info": "اطلاعات", "wheel": "چرخ شانس", - "menu": "منو" + "menu": "منو", + "gift": "هدیه" }, "notifications": { "ticketNotifications": "اعلان‌های تیکت", @@ -1366,6 +1367,8 @@ "darkTheme": "پوسته تیره", "emailAuth": "احراز هویت ایمیل", "emailAuthDesc": "اجازه ورود با ایمیل", + "giftEnabled": "اشتراک هدیه", + "giftEnabledDesc": "امکان ارسال اشتراک به عنوان هدیه به کاربر دیگر", "favoritesEmpty": "هنوز علاقه‌مندی ندارید", "favoritesHint": "ستاره بزنید تا تنظیمات اینجا اضافه شوند", "interfaceOptions": "گزینه‌های رابط کاربری", @@ -1407,7 +1410,8 @@ "referral": "معرفی", "support": "پشتیبانی", "info": "اطلاعات", - "admin": "پنل مدیریت" + "admin": "پنل مدیریت", + "language": "زبان" }, "descriptions": { "home": "دکمه کابینت شخصی", @@ -1416,7 +1420,8 @@ "referral": "برنامه معرفی", "support": "تیکت‌های پشتیبانی", "info": "بخش اطلاعات", - "admin": "پنل مدیریت وب" + "admin": "پنل مدیریت وب", + "language": "انتخاب زبان ربات" }, "styles": { "default": "بدون رنگ", @@ -1425,6 +1430,26 @@ "danger": "قرمز" } }, + "menuEditor": { + "dragHint": "ردیف‌ها را بکشید تا ترتیب تغییر کند", + "dragToReorder": "برای مرتب‌سازی بکشید", + "row": "ردیف", + "addRow": "افزودن ردیف", + "addButton": "افزودن دکمه", + "addUrlButton": "دکمه URL (لینک)", + "builtinButtons": "بخش‌های داخلی", + "resetConfirm": "منو به تنظیمات پیش‌فرض بازنشانی شود؟ تمام تغییرات از بین می‌رود.", + "buttonTextPlaceholder": "متن دکمه", + "customLabelsHint": "متن دکمه برای هر زبان ربات", + "moveUp": "انتقال به بالا", + "moveDown": "انتقال به پایین", + "openIn": "باز کردن در", + "openMode": { + "external": "مرورگر خارجی", + "webapp": "مینی‌اپ تلگرام" + }, + "invalidUrl": "لطفاً برای تمام دکمه‌های سفارشی یک URL معتبر وارد کنید (http:// یا https://)" + }, "apps": { "title": "مدیریت برنامه‌ها", "addApp": "افزودن برنامه", @@ -2186,6 +2211,7 @@ "form": { "name": "نام گروه", "namePlaceholder": "مشتریان VIP", + "nameRequired": "نام گروه را وارد کنید", "categoryDiscounts": "تخفیف‌های دسته‌بندی", "periodDiscounts": "تخفیف‌های دوره‌ای", "add": "افزودن", @@ -2197,6 +2223,7 @@ "rub": "روبل", "autoAssignHint": "0 = تخصیص خودکار نشود", "applyToAddons": "اعمال به خدمات اضافی", + "isDefault": "گروه پیش‌فرض", "cancel": "انصراف", "saving": "در حال ذخیره...", "save": "ذخیره" @@ -2964,10 +2991,10 @@ "content": "محتوا", "save": "ذخیره", "back": "بازگشت", - "invalidSlug": "Slug \u0641\u0642\u0637 \u0645\u06cc\u200c\u062a\u0648\u0627\u0646\u062f \u0634\u0627\u0645\u0644 \u062d\u0631\u0648\u0641 \u06a9\u0648\u0686\u06a9\u060c \u0627\u0639\u062f\u0627\u062f \u0648 \u062e\u0637 \u062a\u06cc\u0631\u0647 \u0628\u0627\u0634\u062f", - "titleRequired": "\u0639\u0646\u0648\u0627\u0646 \u0627\u0644\u0632\u0627\u0645\u06cc \u0627\u0633\u062a", - "noTariffs": "\u062d\u062f\u0627\u0642\u0644 \u06cc\u06a9 \u062a\u0639\u0631\u0641\u0647 \u0627\u0646\u062a\u062e\u0627\u0628 \u06a9\u0646\u06cc\u062f", - "noPaymentMethods": "\u062d\u062f\u0627\u0642\u0644 \u06cc\u06a9 \u0631\u0648\u0634 \u067e\u0631\u062f\u0627\u062e\u062a \u0627\u0636\u0627\u0641\u0647 \u06a9\u0646\u06cc\u062f", + "invalidSlug": "Slug فقط می‌تواند شامل حروف کوچک، اعداد و خط تیره باشد", + "titleRequired": "عنوان الزامی است", + "noTariffs": "حداقل یک تعرفه انتخاب کنید", + "noPaymentMethods": "حداقل یک روش پرداخت اضافه کنید", "selectMethods": "انتخاب روش‌های پرداخت موجود", "noSystemMethods": "هیچ روش پرداختی در سیستم پیکربندی نشده است", "methodOrder": "برای تغییر ترتیب بکشید", @@ -3568,9 +3595,9 @@ "copy": "کپی", "copied": "کپی شد!", "copyLink": "کپی لینک", - "giftToggleLabel": "\u0646\u0648\u0639 \u062e\u0631\u06cc\u062f", - "pollTimedOut": "\u0632\u0645\u0627\u0646 \u0628\u06cc\u0634\u062a\u0631\u06cc \u0637\u0648\u0644 \u06a9\u0634\u06cc\u062f", - "pollTimedOutDesc": "\u067e\u0631\u062f\u0627\u0632\u0634 \u067e\u0631\u062f\u0627\u062e\u062a \u0628\u06cc\u0634\u062a\u0631 \u0627\u0632 \u062d\u062f \u0645\u0639\u0645\u0648\u0644 \u0637\u0648\u0644 \u06a9\u0634\u06cc\u062f\u0647 \u0627\u0633\u062a. \u0645\u06cc\u200c\u062a\u0648\u0627\u0646\u06cc\u062f \u062f\u0648\u0628\u0627\u0631\u0647 \u0628\u0631\u0631\u0633\u06cc \u06a9\u0646\u06cc\u062f.", + "giftToggleLabel": "نوع خرید", + "pollTimedOut": "زمان بیشتری طول کشید", + "pollTimedOutDesc": "پردازش پرداخت بیشتر از حد معمول طول کشیده است. می‌توانید دوباره بررسی کنید.", "pendingActivation": "اشتراک آماده فعال‌سازی", "pendingActivationDesc": "شما قبلاً اشتراک فعالی دارید. فعال‌سازی اشتراک جدید جایگزین اشتراک فعلی می‌شود.", "activateNow": "فعال‌سازی", @@ -3609,5 +3636,63 @@ "nDays": "{{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": "نام کاربری تلگرام قابل تأیید نبود. ممکن است گیرنده اعلان هدیه را دریافت نکند." + } } } diff --git a/src/locales/ru.json b/src/locales/ru.json index 9fc892c..213ad6c 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -44,7 +44,8 @@ "polls": "Опросы", "info": "Информация", "wheel": "Колесо удачи", - "menu": "Меню" + "menu": "Меню", + "gift": "Подарить" }, "notifications": { "ticketNotifications": "Уведомления о тикетах", @@ -1705,6 +1706,8 @@ "autoFullscreenDesc": "В Telegram WebApp", "emailAuth": "Email авторизация", "emailAuthDesc": "Регистрация и вход через email", + "giftEnabled": "Подписка в подарок", + "giftEnabledDesc": "Возможность отправить подписку в подарок другому пользователю", "availableThemes": "Доступные темы", "darkTheme": "Тёмная", "lightTheme": "Светлая", @@ -2256,7 +2259,8 @@ "referral": "Рефералы", "support": "Поддержка", "info": "Информация", - "admin": "Админка" + "admin": "Админка", + "language": "Язык" }, "descriptions": { "home": "Кнопка личного кабинета", @@ -2265,7 +2269,8 @@ "referral": "Реферальная программа", "support": "Обращения в поддержку", "info": "Информационный раздел", - "admin": "Веб-админка" + "admin": "Веб-админка", + "language": "Выбор языка" }, "styles": { "default": "Без цвета", @@ -2274,6 +2279,26 @@ "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": { "title": "Управление приложениями", "addApp": "Добавить приложение", @@ -3045,6 +3070,7 @@ "form": { "name": "Название группы", "namePlaceholder": "VIP клиенты", + "nameRequired": "Введите название группы", "categoryDiscounts": "Скидки по категориям", "periodDiscounts": "Скидки по периодам", "add": "Добавить", @@ -3056,6 +3082,7 @@ "rub": "руб.", "autoAssignHint": "0 = не назначать автоматически", "applyToAddons": "Применять к дополнительным услугам", + "isDefault": "Группа по умолчанию", "cancel": "Отмена", "saving": "Сохранение...", "save": "Сохранить" @@ -3845,7 +3872,7 @@ "status_failed": "Ошибка", "status_expired": "Истёк", "noPurchases": "Нет покупок", - "showing": "Показано {{from}}\u2013{{to}} из {{total}}", + "showing": "Показано {{from}}–{{to}} из {{total}}", "page": "Стр. {{current}} из {{total}}", "prev": "Назад", "next": "Далее" @@ -4676,5 +4703,63 @@ "nMonths_few": "{{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-юзернейм. Получатель может не получить уведомление о подарке." + } } } diff --git a/src/locales/zh.json b/src/locales/zh.json index 008aabf..c0b7307 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -44,7 +44,8 @@ "polls": "问卷", "info": "信息", "wheel": "幸运转盘", - "menu": "菜单" + "menu": "菜单", + "gift": "赠送" }, "notifications": { "ticketNotifications": "工单通知", @@ -1404,6 +1405,8 @@ "darkTheme": "暗色主题", "emailAuth": "邮箱认证", "emailAuthDesc": "允许通过邮箱登录", + "giftEnabled": "赠送订阅", + "giftEnabledDesc": "允许用户将订阅作为礼物赠送给其他用户", "favoritesEmpty": "暂无收藏", "favoritesHint": "点击星标将设置添加到这里", "interfaceOptions": "界面选项", @@ -1445,7 +1448,8 @@ "referral": "推荐", "support": "支持", "info": "信息", - "admin": "管理面板" + "admin": "管理面板", + "language": "语言" }, "descriptions": { "home": "个人中心按钮", @@ -1454,7 +1458,8 @@ "referral": "推荐计划", "support": "支持工单", "info": "信息板块", - "admin": "网页管理面板" + "admin": "网页管理面板", + "language": "机器人语言选择" }, "styles": { "default": "无颜色", @@ -1463,6 +1468,26 @@ "danger": "红色" } }, + "menuEditor": { + "dragHint": "拖拽行以重新排序", + "dragToReorder": "拖拽排序", + "row": "行", + "addRow": "添加行", + "addButton": "添加按钮", + "addUrlButton": "URL按钮(链接)", + "builtinButtons": "内置栏目", + "resetConfirm": "将菜单重置为默认设置?所有更改将丢失。", + "buttonTextPlaceholder": "按钮文本", + "customLabelsHint": "每种语言的按钮文本", + "moveUp": "上移", + "moveDown": "下移", + "openIn": "打开方式", + "openMode": { + "external": "外部浏览器", + "webapp": "Telegram 小程序" + }, + "invalidUrl": "请为所有自定义按钮提供有效的URL(http:// 或 https://)" + }, "apps": { "title": "应用管理", "addApp": "添加应用", @@ -2185,6 +2210,7 @@ "form": { "name": "组名称", "namePlaceholder": "VIP客户", + "nameRequired": "请输入组名称", "categoryDiscounts": "分类折扣", "periodDiscounts": "期间折扣", "add": "添加", @@ -2196,6 +2222,7 @@ "rub": "卢布", "autoAssignHint": "0 = 不自动分配", "applyToAddons": "应用于附加服务", + "isDefault": "默认组", "cancel": "取消", "saving": "保存中...", "save": "保存" @@ -2963,10 +2990,10 @@ "content": "内容", "save": "保存", "back": "返回", - "invalidSlug": "Slug\u53ea\u80fd\u5305\u542b\u5c0f\u5199\u5b57\u6bcd\u3001\u6570\u5b57\u548c\u8fde\u5b57\u7b26", - "titleRequired": "\u6807\u9898\u4e3a\u5fc5\u586b\u9879", - "noTariffs": "\u8bf7\u81f3\u5c11\u9009\u62e9\u4e00\u4e2a\u5957\u9910", - "noPaymentMethods": "\u8bf7\u81f3\u5c11\u6dfb\u52a0\u4e00\u4e2a\u652f\u4ed8\u65b9\u5f0f", + "invalidSlug": "Slug只能包含小写字母、数字和连字符", + "titleRequired": "标题为必填项", + "noTariffs": "请至少选择一个套餐", + "noPaymentMethods": "请至少添加一个支付方式", "selectMethods": "选择可用的支付方式", "noSystemMethods": "系统中未配置任何支付方式", "methodOrder": "拖动以调整顺序", @@ -3567,9 +3594,9 @@ "copy": "复制", "copied": "已复制!", "copyLink": "复制链接", - "giftToggleLabel": "\u8d2d\u4e70\u7c7b\u578b", - "pollTimedOut": "\u5904\u7406\u65f6\u95f4\u8f83\u957f", - "pollTimedOutDesc": "\u4ed8\u6b3e\u5904\u7406\u65f6\u95f4\u6bd4\u5e73\u65f6\u957f\u3002\u60a8\u53ef\u4ee5\u5c1d\u8bd5\u518d\u6b21\u68c0\u67e5\u3002", + "giftToggleLabel": "购买类型", + "pollTimedOut": "处理时间较长", + "pollTimedOutDesc": "付款处理时间比平时长。您可以尝试再次检查。", "pendingActivation": "订阅准备激活", "pendingActivationDesc": "您已有活跃订阅。激活新订阅将替换当前订阅。", "activateNow": "立即激活", @@ -3608,5 +3635,63 @@ "nDays": "{{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 用户名。收件人可能不会收到礼物通知。" + } } } diff --git a/src/pages/AdminPayments.tsx b/src/pages/AdminPayments.tsx index 3769529..95b8a42 100644 --- a/src/pages/AdminPayments.tsx +++ b/src/pages/AdminPayments.tsx @@ -228,7 +228,7 @@ export default function AdminPayments() { {t('admin.payments.openLink')} diff --git a/src/pages/AdminPromoGroupCreate.tsx b/src/pages/AdminPromoGroupCreate.tsx index a10a793..a9ee620 100644 --- a/src/pages/AdminPromoGroupCreate.tsx +++ b/src/pages/AdminPromoGroupCreate.tsx @@ -61,6 +61,7 @@ export default function AdminPromoGroupCreate() { const [trafficDiscount, setTrafficDiscount] = useState(0); const [deviceDiscount, setDeviceDiscount] = useState(0); const [applyToAddons, setApplyToAddons] = useState(true); + const [isDefault, setIsDefault] = useState(false); const [autoAssignSpent, setAutoAssignSpent] = useState(0); const [periodDiscounts, setPeriodDiscounts] = useState([]); @@ -79,6 +80,7 @@ export default function AdminPromoGroupCreate() { setTrafficDiscount(data.traffic_discount_percent || 0); setDeviceDiscount(data.device_discount_percent || 0); setApplyToAddons(data.apply_discounts_to_addons ?? true); + setIsDefault(data.is_default ?? false); setAutoAssignSpent( data.auto_assign_total_spent_kopeks ? data.auto_assign_total_spent_kopeks / 100 : 0, ); @@ -151,7 +153,8 @@ export default function AdminPromoGroupCreate() { period_discounts: Object.keys(periodDiscountsRecord).length > 0 ? periodDiscountsRecord : undefined, 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) { @@ -388,6 +391,24 @@ export default function AdminPromoGroupCreate() { {t('admin.promoGroups.form.applyToAddons')} + + {/* Default group */} +
{/* Footer */} diff --git a/src/pages/AdminSettings.tsx b/src/pages/AdminSettings.tsx index 9b429ed..7525a96 100644 --- a/src/pages/AdminSettings.tsx +++ b/src/pages/AdminSettings.tsx @@ -9,7 +9,7 @@ import { MENU_SECTIONS, MenuItem, formatSettingKey } from '../components/admin'; import { usePlatform } from '../platform/hooks/usePlatform'; import { AnalyticsTab } from '../components/admin/AnalyticsTab'; 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 { FavoritesTab } from '../components/admin/FavoritesTab'; import { SettingsTab } from '../components/admin/SettingsTab'; @@ -176,7 +176,7 @@ export default function AdminSettings() { case 'theme': return ; case 'buttons': - return ; + return ; case 'favorites': return ( // SessionStorage key for Telegram link CSRF 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() { const containerRef = useRef(null); const navigate = useNavigate(); const { showToast } = useToast(); const { t } = useTranslation(); 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({ + 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(() => { - if (!containerRef.current || !botUsername) return; + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + // Shared handler for link result + const handleLinkResult = async (response: Awaited>) => { + 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; while (container.firstChild) { container.removeChild(container.firstChild); } - // Global callback invoked by the Telegram Login Widget const callbackName = '__onTelegramLinkAuth'; (window as unknown as Record)[callbackName] = async ( user: Record, ) => { + if (!mountedRef.current) return; try { const response = await authApi.linkTelegram({ id: user.id as number, @@ -56,17 +155,14 @@ function TelegramLinkWidget() { auth_date: user.auth_date as number, hash: user.hash as string, }); - 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') }); - } + if (mountedRef.current) await handleLinkResult(response); } catch (err: unknown) { - showToast({ - type: 'error', - message: getErrorDetail(err) || t('profile.accounts.linkError'), - }); + if (mountedRef.current) { + showToast({ + type: 'error', + message: getErrorDetail(err) || t('profile.accounts.linkError'), + }); + } } }; @@ -87,12 +183,33 @@ function TelegramLinkWidget() { container.removeChild(container.firstChild); } }; - }, [botUsername, navigate, showToast, t, queryClient]); + }, [isOIDC, botUsername, navigate, showToast, t, queryClient]); - if (!botUsername) { + if (!botUsername && !isOIDC) { return null; } + if (isOIDC) { + return ( + + ); + } + return
; } diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 0cc715c..7564778 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -14,6 +14,8 @@ import SubscriptionCardActive from '../components/dashboard/SubscriptionCardActi import SubscriptionCardExpired from '../components/dashboard/SubscriptionCardExpired'; import TrialOfferCard from '../components/dashboard/TrialOfferCard'; import StatsGrid from '../components/dashboard/StatsGrid'; +import { giftApi } from '../api/gift'; +import PendingGiftCard from '../components/dashboard/PendingGiftCard'; import { API } from '../config/constants'; const ChevronRightIcon = () => ( @@ -80,6 +82,13 @@ export default function Dashboard() { retry: false, }); + const { data: pendingGifts } = useQuery({ + queryKey: ['pending-gifts'], + queryFn: giftApi.getPendingGifts, + staleTime: 30_000, + retry: false, + }); + const activateTrialMutation = useMutation({ mutationFn: subscriptionApi.activateTrial, onSuccess: () => { @@ -221,6 +230,9 @@ export default function Dashboard() {

{t('dashboard.yourSubscription')}

+ {/* Pending Gift Activations */} + {pendingGifts && pendingGifts.length > 0 && } + {/* Subscription Status Card */} {subLoading ? (
diff --git a/src/pages/GiftResult.tsx b/src/pages/GiftResult.tsx new file mode 100644 index 0000000..6193e16 --- /dev/null +++ b/src/pages/GiftResult.tsx @@ -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 ( + + +
+

+ {t('gift.processing', 'Processing your gift...')} +

+

+ {t('gift.pendingDesc', 'Please wait while we process your payment')} +

+
+
+ ); +} + +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 ( + + + +
+

{t('gift.successTitle', 'Gift sent!')}

+ {tariffName && periodDays !== null && ( +

+ {tariffName} — {periodDays} {t('gift.days', 'days')} +

+ )} + {recipientContact && ( +

+ {t('gift.successDesc', { + contact: recipientContact, + defaultValue: `Sent to ${recipientContact}`, + })} +

+ )} + {giftMessage && ( +

“{giftMessage}”

+ )} +
+ + {warning && ( +
+

{t(`gift.warning.${warning}`)}

+
+ )} + + +
+ ); +} + +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 ( + + {/* Info icon */} +
+ + + +
+ +
+

+ {t('gift.pendingActivationTitle', 'Gift pending activation')} +

+ {tariffName && periodDays !== null && ( +

+ {tariffName} — {periodDays} {t('gift.days', 'days')} +

+ )} + {recipientContact && ( +

+ {t('gift.successDesc', { + contact: recipientContact, + defaultValue: `Sent to ${recipientContact}`, + })} +

+ )} +

+ {t( + 'gift.pendingActivationDesc', + 'The recipient currently has an active subscription. Your gift will be activated once their current subscription expires.', + )} +

+
+ + {warning && ( +
+

{t(`gift.warning.${warning}`)}

+
+ )} + + +
+ ); +} + +function FailedState() { + const { t } = useTranslation(); + const navigate = useNavigate(); + + return ( + + + +
+

+ {t('gift.failedTitle', 'Something went wrong')} +

+

+ {t('gift.failedDesc', 'Your gift could not be processed. Please try again.')} +

+
+ + +
+ ); +} + +function PollErrorState() { + const { t } = useTranslation(); + const navigate = useNavigate(); + + return ( + +
+ + + +
+ +
+

+ {t('gift.pollErrorTitle', 'Could not check gift status')} +

+

+ {t( + 'gift.pollErrorDesc', + 'Your purchase was successful. Check your dashboard for details.', + )} +

+
+ + +
+ ); +} + +function PollTimedOutState({ onRetry }: { onRetry: () => void }) { + const { t } = useTranslation(); + + return ( + +
+ + + +
+
+

+ {t('gift.pollTimeout', 'Taking longer than expected')} +

+

+ {t( + 'gift.pollTimeoutDesc', + 'Payment processing is taking longer than usual. You can try checking again.', + )} +

+
+ +
+ ); +} + +function NoTokenState() { + const { t } = useTranslation(); + const navigate = useNavigate(); + + return ( + +
+ + + +
+
+

{t('gift.noToken', 'Invalid link')}

+

+ {t('gift.noTokenDesc', 'This gift link is invalid or has expired.')} +

+
+ +
+ ); +} + +// ============================================================ +// 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 ( +
+
+ +
+
+ ); + } + + 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 ( +
+
+ {isError ? ( + + ) : isDelivered ? ( + + ) : isPendingActivation ? ( + + ) : isFailed ? ( + + ) : pollTimedOut ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/src/pages/GiftSubscription.tsx b/src/pages/GiftSubscription.tsx new file mode 100644 index 0000000..649e653 --- /dev/null +++ b/src/pages/GiftSubscription.tsx @@ -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, +): 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 ( +
+
+
+
+
+ ); +} + +function ErrorState({ message }: { message: string }) { + const { t } = useTranslation(); + + return ( +
+
+
+ + + +
+

{t('gift.failedTitle', 'Error')}

+

{message}

+
+
+ ); +} + +function DisabledState() { + const { t } = useTranslation(); + const navigate = useNavigate(); + + useEffect(() => { + const timer = setTimeout(() => navigate('/'), 3000); + return () => clearTimeout(timer); + }, [navigate]); + + return ( +
+
+
+ + + +
+

+ {t('gift.featureDisabled', 'Gift subscriptions are currently unavailable')} +

+

{t('gift.redirecting', 'Redirecting...')}

+
+
+ ); +} + +function PeriodTabs({ + periods, + selectedDays, + onSelect, +}: { + periods: GiftTariffPeriod[]; + selectedDays: number; + onSelect: (days: number) => void; +}) { + const { t } = useTranslation(); + + return ( +
+ {periods.map((period) => ( + + ))} +
+ ); +} + +function TariffCard({ + tariff, + isSelected, + selectedPeriod, + onSelect, +}: { + tariff: GiftTariff; + isSelected: boolean; + selectedPeriod: GiftTariffPeriod | undefined; + onSelect: () => void; +}) { + const { t } = useTranslation(); + + return ( + + ); +} + +function PaymentModeToggle({ + mode, + onToggle, + balanceLabel, +}: { + mode: 'balance' | 'gateway'; + onToggle: (mode: 'balance' | 'gateway') => void; + balanceLabel: string; +}) { + const { t } = useTranslation(); + + return ( +
+ + +
+ ); +} + +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 ( +
+ + + {/* Sub-options */} + {isSelected && hasSubOptions && ( +
+
+ {method.sub_options!.map((opt) => ( + + ))} +
+
+ )} +
+ ); +} + +function RecipientSection({ value, onChange }: { value: string; onChange: (v: string) => void }) { + const { t } = useTranslation(); + + return ( +
+
+ + 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" + /> +

+ {t( + 'gift.recipientHint', + 'Enter the email or Telegram username of the person you want to gift', + )} +

+
+
+ ); +} + +function GiftMessageSection({ value, onChange }: { value: string; onChange: (v: string) => void }) { + const { t } = useTranslation(); + + return ( + + +
+ +