From 880b2d45fe8966f510f77b83d6513e8be0ec1e47 Mon Sep 17 00:00:00 2001
From: Fringg
Date: Mon, 9 Mar 2026 06:23:02 +0300
Subject: [PATCH 01/17] fix: support OIDC mode in TelegramLinkWidget for
account linking
TelegramLinkWidget used legacy widget only, ignoring OIDC config.
Now queries widgetConfig from backend, supports OIDC popup flow with
id_token, and falls back to bot_username from server config.
---
src/api/auth.ts | 3 +-
src/pages/ConnectedAccounts.tsx | 149 ++++++++++++++++++++++++++++----
2 files changed, 135 insertions(+), 17 deletions(-)
diff --git a/src/api/auth.ts b/src/api/auth.ts
index 228e8f7..4a66f2d 100644
--- a/src/api/auth.ts
+++ b/src/api/auth.ts
@@ -248,10 +248,11 @@ export const authApi = {
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 (
data:
| { init_data: string }
+ | { id_token: string }
| {
id: number;
first_name: string;
diff --git a/src/pages/ConnectedAccounts.tsx b/src/pages/ConnectedAccounts.tsx
index 977e67e..2543da6 100644
--- a/src/pages/ConnectedAccounts.tsx
+++ b/src/pages/ConnectedAccounts.tsx
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import { authApi } from '../api/auth';
+import { brandingApi, type TelegramWidgetConfig } from '../api/branding';
import { useToast } from '../components/Toast';
import { Card } from '@/components/data-display/Card';
import { Button } from '@/components/primitives/Button';
@@ -24,28 +25,126 @@ const isLinkableProvider = (provider: string): boolean =>
// 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 (
+ {
+ setOidcLoading(true);
+ if (window.Telegram?.Login) {
+ window.Telegram.Login.open();
+ } else {
+ setOidcLoading(false);
+ }
+ }}
+ >
+ {t('profile.accounts.link')}
+
+ );
+ }
+
return
;
}
From a49520566e46eb0cfdc22a3661c5ba405dc6cc92 Mon Sep 17 00:00:00 2001
From: Fringg
Date: Mon, 9 Mar 2026 18:49:21 +0300
Subject: [PATCH 02/17] feat: add gift subscription API client and feature flag
---
src/api/branding.ts | 14 ++++++
src/api/gift.ts | 87 ++++++++++++++++++++++++++++++++++++
src/hooks/useFeatureFlags.ts | 10 +++++
3 files changed, 111 insertions(+)
create mode 100644 src/api/gift.ts
diff --git a/src/api/branding.ts b/src/api/branding.ts
index 235d511..5136814 100644
--- a/src/api/branding.ts
+++ b/src/api/branding.ts
@@ -23,6 +23,10 @@ export interface EmailAuthEnabled {
enabled: boolean;
}
+export interface GiftEnabled {
+ enabled: boolean;
+}
+
export interface TelegramWidgetConfig {
bot_username: string;
size: 'large' | 'medium' | 'small';
@@ -251,6 +255,16 @@ export const brandingApi = {
return response.data;
},
+ // Get gift enabled (public, no auth required)
+ getGiftEnabled: async (): Promise => {
+ try {
+ const response = await apiClient.get('/cabinet/branding/gift-enabled');
+ return response.data;
+ } catch {
+ return { enabled: false };
+ }
+ },
+
// Get analytics counters (public, no auth required)
getAnalyticsCounters: async (): Promise => {
try {
diff --git a/src/api/gift.ts b/src/api/gift.ts
new file mode 100644
index 0000000..35b3046
--- /dev/null
+++ b/src/api/gift.ts
@@ -0,0 +1,87 @@
+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: string;
+ purchase_token: string;
+ payment_url: string | null;
+}
+
+export interface GiftPurchaseStatus {
+ status: string;
+ is_gift: boolean;
+ recipient_contact_value: string | null;
+ gift_message: string | null;
+ tariff_name: string | null;
+ period_days: number | 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;
+ },
+};
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,
};
}
From 814b1f5e96f968d9bc2829ba395ac187fa4d2e11 Mon Sep 17 00:00:00 2001
From: Fringg
Date: Mon, 9 Mar 2026 18:56:16 +0300
Subject: [PATCH 03/17] feat: add GiftSubscription and GiftResult pages
---
src/pages/GiftResult.tsx | 380 +++++++++++++
src/pages/GiftSubscription.tsx | 983 +++++++++++++++++++++++++++++++++
2 files changed, 1363 insertions(+)
create mode 100644 src/pages/GiftResult.tsx
create mode 100644 src/pages/GiftSubscription.tsx
diff --git a/src/pages/GiftResult.tsx b/src/pages/GiftResult.tsx
new file mode 100644
index 0000000..a1361b8
--- /dev/null
+++ b/src/pages/GiftResult.tsx
@@ -0,0 +1,380 @@
+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
+
+// ============================================================
+// Sub-components
+// ============================================================
+
+function PendingState() {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+ {t('gift.processing', 'Processing your gift...')}
+
+
+ {t('gift.processingDesc', 'Please wait while we process your payment')}
+
+
+
+ );
+}
+
+function DeliveredState({
+ recipientContact,
+ tariffName,
+ periodDays,
+ giftMessage,
+}: {
+ recipientContact: string | null;
+ tariffName: string | null;
+ periodDays: number | null;
+ giftMessage: string | null;
+}) {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+
+ return (
+
+
+
+
+
{t('gift.sent', 'Gift sent!')}
+ {tariffName && periodDays !== null && (
+
+ {tariffName} — {periodDays} {t('gift.days', 'days')}
+
+ )}
+ {recipientContact && (
+
+ {t('gift.sentTo', {
+ contact: recipientContact,
+ defaultValue: `Sent to ${recipientContact}`,
+ })}
+
+ )}
+ {giftMessage && (
+
“{giftMessage}”
+ )}
+
+
+ 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')}
+
+
+ );
+}
+
+function PendingActivationState({
+ recipientContact,
+ tariffName,
+ periodDays,
+}: {
+ recipientContact: string | null;
+ tariffName: string | null;
+ periodDays: number | null;
+}) {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+
+ return (
+
+ {/* Info icon */}
+
+
+
+
+ {t('gift.pendingActivation', 'Gift pending activation')}
+
+ {tariffName && periodDays !== null && (
+
+ {tariffName} — {periodDays} {t('gift.days', 'days')}
+
+ )}
+ {recipientContact && (
+
+ {t('gift.sentTo', {
+ 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.',
+ )}
+
+
+
+ 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')}
+
+
+ );
+}
+
+function FailedState() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+
+ return (
+
+
+
+
+
+ {t('gift.failed', 'Something went wrong')}
+
+
+ {t('gift.failedDesc', 'Your gift could not be processed. Please try again.')}
+
+
+
+ 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')}
+
+
+ );
+}
+
+function PollTimedOutState({ onRetry }: { onRetry: () => void }) {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+ {t('gift.pollTimedOut', 'Taking longer than expected')}
+
+
+ {t(
+ 'gift.pollTimedOutDesc',
+ 'Payment processing is taking longer than usual. You can try checking again.',
+ )}
+
+
+
+ {t('common.retry', 'Retry')}
+
+
+ );
+}
+
+function NoTokenState() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+
+ return (
+
+
+
+
{t('gift.invalidLink', 'Invalid link')}
+
+ {t('gift.invalidLinkDesc', 'This gift link is invalid or has expired.')}
+
+
+ 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.giftNewSubscription', 'Gift a subscription')}
+
+
+ );
+}
+
+// ============================================================
+// Main Component
+// ============================================================
+
+export default function GiftResult() {
+ const [searchParams] = useSearchParams();
+ const token = searchParams.get('token');
+ const mode = searchParams.get('mode');
+
+ 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') 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';
+
+ return (
+
+
+ {isError ? (
+
+ ) : isDelivered ? (
+
+ ) : isPendingActivation ? (
+
+ ) : isFailed ? (
+
+ ) : pollTimedOut ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/src/pages/GiftSubscription.tsx b/src/pages/GiftSubscription.tsx
new file mode 100644
index 0000000..da459c0
--- /dev/null
+++ b/src/pages/GiftSubscription.tsx
@@ -0,0 +1,983 @@
+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 trimmed.length >= 4;
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.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.error', 'Error')}
+
{message}
+
+
+ );
+}
+
+function DisabledState() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ const timer = setTimeout(() => navigate('/'), 3000);
+ return () => clearTimeout(timer);
+ }, [navigate]);
+
+ return (
+
+
+
+
+ {t('gift.disabled', 'Gift subscriptions are currently unavailable')}
+
+
+ {t('gift.disabledRedirect', 'Redirecting to dashboard...')}
+
+
+
+ );
+}
+
+function PeriodTabs({
+ periods,
+ selectedDays,
+ onSelect,
+}: {
+ periods: GiftTariffPeriod[];
+ selectedDays: number;
+ onSelect: (days: number) => void;
+}) {
+ const { t } = useTranslation();
+
+ return (
+
+ {periods.map((period) => (
+ 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)}
+
+ ))}
+
+ );
+}
+
+function TariffCard({
+ tariff,
+ isSelected,
+ selectedPeriod,
+ onSelect,
+}: {
+ tariff: GiftTariff;
+ isSelected: boolean;
+ selectedPeriod: GiftTariffPeriod | undefined;
+ onSelect: () => void;
+}) {
+ const { t } = useTranslation();
+
+ return (
+
+ {/* Header */}
+
+
+
{tariff.name}
+ {tariff.description && (
+
{tariff.description}
+ )}
+
+
+ {isSelected && (
+
+
+
+ )}
+
+
+
+ {/* Info row */}
+
+
+
+
+
+ {tariff.traffic_limit_gb === 0 ? '\u221E' : tariff.traffic_limit_gb}{' '}
+ {t('landing.gb', 'GB')}
+
+
+
+
+
+ {tariff.device_limit} {t('landing.devices', 'devices')}
+
+
+
+ {/* Price */}
+ {selectedPeriod && (
+
+
+
+ {formatPrice(selectedPeriod.price_kopeks)}
+
+ {selectedPeriod.original_price_kopeks != null &&
+ selectedPeriod.original_price_kopeks > selectedPeriod.price_kopeks && (
+ <>
+
+ {formatPrice(selectedPeriod.original_price_kopeks)}
+
+ {selectedPeriod.discount_percent != null && (
+
+ -{selectedPeriod.discount_percent}%
+
+ )}
+ >
+ )}
+
+
+ )}
+
+ );
+}
+
+function PaymentModeToggle({
+ mode,
+ onToggle,
+ balanceLabel,
+}: {
+ mode: 'balance' | 'gateway';
+ onToggle: (mode: 'balance' | 'gateway') => void;
+ balanceLabel: string;
+}) {
+ const { t } = useTranslation();
+
+ return (
+
+ 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}
+
+ 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')}
+
+
+ );
+}
+
+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 (
+
+
+ {/* Icon */}
+ {method.icon_url && (
+
+
+
+ )}
+
+ {/* Text */}
+
+
{method.display_name}
+ {method.description && (
+
{method.description}
+ )}
+
+
+ {/* Radio */}
+
+
+
+ {/* Sub-options */}
+ {isSelected && hasSubOptions && (
+
+
+ {method.sub_options!.map((opt) => (
+ 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}
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+function RecipientSection({ value, onChange }: { value: string; onChange: (v: string) => void }) {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+ {t('gift.recipient', 'Recipient')}
+
+
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 (
+
+
+
+
+ {t('gift.giftMessage', 'Personal message')}
+
+
+
+
+ );
+}
+
+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 (
+
+ {/* Summary */}
+
+ {selectedTariff && (
+
+
+ {t('gift.selectedTariff', 'Tariff')}
+
+
{selectedTariff.name}
+
+ )}
+ {selectedPeriod && (
+
+
+ {t('gift.period', 'Period')}
+
+
+ {formatPeriodLabel(selectedPeriod.days, t)}
+
+
+ )}
+
+
+ {t('gift.total', 'Total')}
+
+
+ {formatPrice(currentPrice)}
+ {selectedPeriod?.original_price_kopeks != null &&
+ selectedPeriod.original_price_kopeks > selectedPeriod.price_kopeks && (
+ <>
+
+ {formatPrice(selectedPeriod.original_price_kopeks)}
+
+ {selectedPeriod.discount_percent != null && (
+
+ -{selectedPeriod.discount_percent}%
+
+ )}
+ >
+ )}
+
+
+
+ {/* Balance info for balance mode */}
+ {paymentMode === 'balance' && (
+
+
+ {t('gift.yourBalance', 'Your balance')}
+
+
+ {formatPrice(config.balance_kopeks)}
+
+
+ )}
+
+
+ {/* Insufficient balance warning */}
+
+ {insufficientBalance && (
+
+
+ {t('gift.insufficientBalance', 'Insufficient balance.')}{' '}
+
+ {t('gift.topUp', 'Top up')}
+
+
+
+ )}
+
+
+ {/* Error */}
+
+ {submitError && (
+
+ {submitError}
+
+ )}
+
+
+ {/* Gift button */}
+
+ {isSubmitting ? (
+
+ ) : (
+ <>
+ {t('gift.giftButton', 'Gift')} {formatPrice(currentPrice)}
+ >
+ )}
+
+
+ );
+}
+
+// ============================================================
+// 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(null);
+ const [selectedPeriodDays, setSelectedPeriodDays] = useState(null);
+ const [recipientValue, setRecipientValue] = useState('');
+ const [giftMessage, setGiftMessage] = useState('');
+ const [paymentMode, setPaymentMode] = useState<'balance' | 'gateway'>('balance');
+ const [selectedMethod, setSelectedMethod] = useState(null);
+ const [selectedSubOption, setSelectedSubOption] = useState(null);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [submitError, setSubmitError] = useState(null);
+
+ // Collect ALL unique periods across ALL tariffs
+ const allPeriods = useMemo(() => {
+ if (!config) return [];
+ const periodMap = new Map();
+ 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'] });
+ navigate('/gift/result?token=' + result.purchase_token + '&mode=balance');
+ }
+ },
+ onError: (err) => {
+ const msg = getApiErrorMessage(
+ err,
+ t('gift.purchaseError', 'Something went wrong. Please try again.'),
+ );
+ setSubmitError(msg);
+ setIsSubmitting(false);
+ },
+ });
+
+ // Submit handler
+ const handleSubmit = () => {
+ if (!canSubmit || isSubmitting) return;
+
+ setIsSubmitting(true);
+ 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 ;
+ }
+
+ // Error state
+ if (error || !config) {
+ const errMsg = getApiErrorMessage(error, t('gift.notFound', 'Gift configuration not found'));
+ return ;
+ }
+
+ // Disabled state
+ if (!config.is_enabled) {
+ return ;
+ }
+
+ const showTariffCards = visibleTariffs.length > 1;
+
+ return (
+
+
+ {/* Header */}
+
+
+ {t('gift.title', 'Gift Subscription')}
+
+
+ {t('gift.subtitle', 'Give the gift of secure internet to someone special')}
+
+
+
+ {/* Two-column layout */}
+
+ {/* Left column */}
+
+ {/* Period tabs */}
+ {allPeriods.length > 0 && (
+
+
+ {t('gift.choosePeriod', 'Choose period')}
+
+
+
+ )}
+
+ {/* Tariff cards */}
+ {showTariffCards && (
+
+
+ {t('gift.chooseTariff', 'Choose tariff')}
+
+
+ {visibleTariffs.map((tariff) => {
+ const period = tariff.periods.find((p) => p.days === selectedPeriodDays);
+ return (
+ setSelectedTariffId(tariff.id)}
+ />
+ );
+ })}
+
+
+ )}
+
+ {/* Recipient */}
+
+
+ {t('gift.recipientSection', 'Recipient')}
+
+ {
+ setRecipientValue(v);
+ setSubmitError(null);
+ }}
+ />
+
+
+ {/* Gift message */}
+
+
+ {t('gift.messageSection', 'Personal message')}
+
+
+
+
+ {/* Payment mode toggle */}
+
+
+ {t('gift.paymentModeSection', 'Payment method')}
+
+
+
+
+ {/* Payment method cards (gateway mode only) */}
+
+ {paymentMode === 'gateway' && config.payment_methods.length > 0 && (
+
+
+ {config.payment_methods.map((method) => (
+
{
+ setSelectedMethod(method.method_id);
+ if (method.sub_options && method.sub_options.length >= 1) {
+ setSelectedSubOption(method.sub_options[0].id);
+ } else {
+ setSelectedSubOption(null);
+ }
+ }}
+ onSelectSubOption={setSelectedSubOption}
+ />
+ ))}
+
+
+ )}
+
+
+
+ {/* Right column (sticky sidebar / bottom on mobile) */}
+
+
+
+
+
+
+ );
+}
From 7890d480e05e87f77ea2fea3ae3a7e955bd167d3 Mon Sep 17 00:00:00 2001
From: Fringg
Date: Mon, 9 Mar 2026 19:04:50 +0300
Subject: [PATCH 04/17] feat: add gift navigation, routes, and i18n
translations
---
src/App.tsx | 22 +++++++
src/components/layout/AppShell/AppHeader.tsx | 4 ++
src/components/layout/AppShell/AppShell.tsx | 20 ++++++-
src/components/layout/AppShell/icons.tsx | 1 +
src/locales/en.json | 50 +++++++++++++++-
src/locales/fa.json | 62 +++++++++++++++++---
src/locales/ru.json | 50 +++++++++++++++-
src/locales/zh.json | 62 +++++++++++++++++---
8 files changed, 249 insertions(+), 22 deletions(-)
diff --git a/src/App.tsx b/src/App.tsx
index 909a8c2..6df73fb 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,26 @@ function App() {
}
/>
+
+
+
+
+
+ }
+ />
+
+
+
+
+
+ }
+ />
{t('nav.referral')}
)}
+ {giftEnabled && (
+
+
+ {t('nav.gift')}
+
+ )}
{isAdmin && (
<>
{/* Separator before admin */}
@@ -415,6 +430,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/locales/en.json b/src/locales/en.json
index 094646a..c1554a6 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",
@@ -3293,7 +3294,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 +4115,50 @@
"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",
+ "retry": "Check again",
+ "noToken": "Invalid link",
+ "tariff": "Tariff",
+ "period": "Period",
+ "giftMessageLabel": "Message",
+ "recipientLabel": "Recipient",
+ "featureDisabled": "Gift feature is temporarily unavailable",
+ "redirecting": "Redirecting..."
}
}
diff --git a/src/locales/fa.json b/src/locales/fa.json
index 93f9501..de87e73 100644
--- a/src/locales/fa.json
+++ b/src/locales/fa.json
@@ -44,7 +44,8 @@
"polls": "نظرسنجی",
"info": "اطلاعات",
"wheel": "چرخ شانس",
- "menu": "منو"
+ "menu": "منو",
+ "gift": "هدیه"
},
"notifications": {
"ticketNotifications": "اعلانهای تیکت",
@@ -2964,10 +2965,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 +3569,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 +3610,50 @@
"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": "بعداً وضعیت را بررسی کنید",
+ "retry": "بررسی مجدد",
+ "noToken": "لینک نامعتبر",
+ "tariff": "طرح",
+ "period": "مدت",
+ "giftMessageLabel": "پیام",
+ "recipientLabel": "گیرنده",
+ "featureDisabled": "قابلیت هدیه موقتاً در دسترس نیست",
+ "redirecting": "در حال انتقال..."
}
}
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 9fc892c..a755e3d 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -44,7 +44,8 @@
"polls": "Опросы",
"info": "Информация",
"wheel": "Колесо удачи",
- "menu": "Меню"
+ "menu": "Меню",
+ "gift": "Подарить"
},
"notifications": {
"ticketNotifications": "Уведомления о тикетах",
@@ -3845,7 +3846,7 @@
"status_failed": "Ошибка",
"status_expired": "Истёк",
"noPurchases": "Нет покупок",
- "showing": "Показано {{from}}\u2013{{to}} из {{total}}",
+ "showing": "Показано {{from}}–{{to}} из {{total}}",
"page": "Стр. {{current}} из {{total}}",
"prev": "Назад",
"next": "Далее"
@@ -4676,5 +4677,50 @@
"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": "Попробуйте проверить статус позже",
+ "retry": "Проверить снова",
+ "noToken": "Ссылка недействительна",
+ "tariff": "Тариф",
+ "period": "Период",
+ "giftMessageLabel": "Сообщение",
+ "recipientLabel": "Получатель",
+ "featureDisabled": "Функция подарков временно недоступна",
+ "redirecting": "Перенаправляем..."
}
}
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 008aabf..a53b03c 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -44,7 +44,8 @@
"polls": "问卷",
"info": "信息",
"wheel": "幸运转盘",
- "menu": "菜单"
+ "menu": "菜单",
+ "gift": "赠送"
},
"notifications": {
"ticketNotifications": "工单通知",
@@ -2963,10 +2964,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 +3568,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 +3609,50 @@
"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": "请稍后再查看状态",
+ "retry": "再次检查",
+ "noToken": "无效链接",
+ "tariff": "套餐",
+ "period": "时长",
+ "giftMessageLabel": "消息",
+ "recipientLabel": "收件人",
+ "featureDisabled": "礼物功能暂时不可用",
+ "redirecting": "正在跳转..."
}
}
From 6ea1de2e8afba93361c48a364ddc5406f6bc5d4b Mon Sep 17 00:00:00 2001
From: Fringg
Date: Mon, 9 Mar 2026 20:34:41 +0300
Subject: [PATCH 05/17] fix: harden gift subscription frontend after
multi-agent review
- Handle expired status in GiftResult (stop polling + show FailedState)
- Add PollErrorState for balance mode poll errors (softer UX)
- Remove non-null assertions in handleSubmit (explicit narrowing)
- Wrap gift routes in ErrorBoundary
- Add pollErrorTitle/pollErrorDesc i18n keys to all 4 locales
---
src/App.tsx | 24 +++--
src/api/gift.ts | 27 ++++-
src/components/dashboard/PendingGiftCard.tsx | 80 ++++++++++++++
src/locales/en.json | 15 ++-
src/locales/fa.json | 15 ++-
src/locales/ru.json | 15 ++-
src/locales/zh.json | 15 ++-
src/pages/Dashboard.tsx | 12 +++
src/pages/GiftResult.tsx | 104 ++++++++++++++++---
src/pages/GiftSubscription.tsx | 44 ++++----
10 files changed, 299 insertions(+), 52 deletions(-)
create mode 100644 src/components/dashboard/PendingGiftCard.tsx
diff --git a/src/App.tsx b/src/App.tsx
index 6df73fb..f0c973f 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -425,21 +425,25 @@ function App() {
-
-
-
-
+
+
+
+
+
+
+
}
/>
-
-
-
-
+
+
+
+
+
+
+
}
/>
(`/cabinet/gift/purchase/${token}`);
return data;
},
+
+ getPendingGifts: async (): Promise => {
+ const { data } = await apiClient.get('/cabinet/gift/pending');
+ return data;
+ },
};
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/locales/en.json b/src/locales/en.json
index c1554a6..ad055ea 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -4152,13 +4152,26 @@
"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..."
+ "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 de87e73..f94bde1 100644
--- a/src/locales/fa.json
+++ b/src/locales/fa.json
@@ -3647,13 +3647,26 @@
"tryAgain": "تلاش مجدد",
"pollTimeout": "پردازش بیش از حد معمول طول کشیده",
"pollTimeoutDesc": "بعداً وضعیت را بررسی کنید",
+ "pollErrorTitle": "بررسی وضعیت هدیه امکانپذیر نیست",
+ "pollErrorDesc": "خرید شما موفقیتآمیز بود. وضعیت را در داشبورد بررسی کنید.",
"retry": "بررسی مجدد",
+ "notFound": "تنظیمات هدیه یافت نشد",
"noToken": "لینک نامعتبر",
+ "noTokenDesc": "این لینک هدیه نامعتبر است یا منقضی شده.",
+ "days": "روز",
"tariff": "طرح",
"period": "مدت",
"giftMessageLabel": "پیام",
"recipientLabel": "گیرنده",
"featureDisabled": "قابلیت هدیه موقتاً در دسترس نیست",
- "redirecting": "در حال انتقال..."
+ "redirecting": "در حال انتقال...",
+ "pending": {
+ "title": "شما یک هدیه دریافت کردید!",
+ "from": "از {{sender}}",
+ "activate": "فعالسازی"
+ },
+ "warning": {
+ "telegram_unresolvable": "نام کاربری تلگرام قابل تأیید نبود. ممکن است گیرنده اعلان هدیه را دریافت نکند."
+ }
}
}
diff --git a/src/locales/ru.json b/src/locales/ru.json
index a755e3d..6abfecc 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -4714,13 +4714,26 @@
"tryAgain": "Попробовать снова",
"pollTimeout": "Обработка занимает больше времени, чем обычно",
"pollTimeoutDesc": "Попробуйте проверить статус позже",
+ "pollErrorTitle": "Не удалось проверить статус подарка",
+ "pollErrorDesc": "Ваша покупка прошла успешно. Проверьте статус на главной странице.",
"retry": "Проверить снова",
+ "notFound": "Конфигурация подарков не найдена",
"noToken": "Ссылка недействительна",
+ "noTokenDesc": "Ссылка на подарок недействительна или просрочена.",
+ "days": "дн.",
"tariff": "Тариф",
"period": "Период",
"giftMessageLabel": "Сообщение",
"recipientLabel": "Получатель",
"featureDisabled": "Функция подарков временно недоступна",
- "redirecting": "Перенаправляем..."
+ "redirecting": "Перенаправляем...",
+ "pending": {
+ "title": "Вам подарили подписку!",
+ "from": "от {{sender}}",
+ "activate": "Активировать"
+ },
+ "warning": {
+ "telegram_unresolvable": "Не удалось проверить этот Telegram-юзернейм. Получатель может не получить уведомление о подарке."
+ }
}
}
diff --git a/src/locales/zh.json b/src/locales/zh.json
index a53b03c..c7eb3b1 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -3646,13 +3646,26 @@
"tryAgain": "重试",
"pollTimeout": "处理时间超出预期",
"pollTimeoutDesc": "请稍后再查看状态",
+ "pollErrorTitle": "无法检查礼物状态",
+ "pollErrorDesc": "您的购买已成功。请在仪表板上查看详情。",
"retry": "再次检查",
+ "notFound": "未找到礼物配置",
"noToken": "无效链接",
+ "noTokenDesc": "此礼物链接无效或已过期。",
+ "days": "天",
"tariff": "套餐",
"period": "时长",
"giftMessageLabel": "消息",
"recipientLabel": "收件人",
"featureDisabled": "礼物功能暂时不可用",
- "redirecting": "正在跳转..."
+ "redirecting": "正在跳转...",
+ "pending": {
+ "title": "您收到了一份礼物!",
+ "from": "来自 {{sender}}",
+ "activate": "激活"
+ },
+ "warning": {
+ "telegram_unresolvable": "无法验证此 Telegram 用户名。收件人可能不会收到礼物通知。"
+ }
}
}
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
index a1361b8..7083d60 100644
--- a/src/pages/GiftResult.tsx
+++ b/src/pages/GiftResult.tsx
@@ -10,6 +10,8 @@ import { AnimatedCrossmark } from '@/components/ui/AnimatedCrossmark';
const MAX_POLL_MS = 10 * 60 * 1000; // 10 minutes
+const KNOWN_WARNINGS = new Set(['telegram_unresolvable']);
+
// ============================================================
// Sub-components
// ============================================================
@@ -29,7 +31,7 @@ function PendingState() {
{t('gift.processing', 'Processing your gift...')}
- {t('gift.processingDesc', 'Please wait while we process your payment')}
+ {t('gift.pendingDesc', 'Please wait while we process your payment')}
@@ -41,11 +43,13 @@ function DeliveredState({
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();
@@ -59,7 +63,7 @@ function DeliveredState({
-
{t('gift.sent', 'Gift sent!')}
+
{t('gift.successTitle', 'Gift sent!')}
{tariffName && periodDays !== null && (
{tariffName} — {periodDays} {t('gift.days', 'days')}
@@ -67,7 +71,7 @@ function DeliveredState({
)}
{recipientContact && (
- {t('gift.sentTo', {
+ {t('gift.successDesc', {
contact: recipientContact,
defaultValue: `Sent to ${recipientContact}`,
})}
@@ -78,6 +82,12 @@ function DeliveredState({
)}
+ {warning && (
+
+
{t(`gift.warning.${warning}`)}
+
+ )}
+
navigate('/')}
@@ -93,10 +103,12 @@ function PendingActivationState({
recipientContact,
tariffName,
periodDays,
+ warning,
}: {
recipientContact: string | null;
tariffName: string | null;
periodDays: number | null;
+ warning: string | null;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -126,7 +138,7 @@ function PendingActivationState({
- {t('gift.pendingActivation', 'Gift pending activation')}
+ {t('gift.pendingActivationTitle', 'Gift pending activation')}
{tariffName && periodDays !== null && (
@@ -135,7 +147,7 @@ function PendingActivationState({
)}
{recipientContact && (
- {t('gift.sentTo', {
+ {t('gift.successDesc', {
contact: recipientContact,
defaultValue: `Sent to ${recipientContact}`,
})}
@@ -149,6 +161,12 @@ function PendingActivationState({
+ {warning && (
+
+
{t(`gift.warning.${warning}`)}
+
+ )}
+
navigate('/')}
@@ -174,7 +192,7 @@ function FailedState() {
- {t('gift.failed', 'Something went wrong')}
+ {t('gift.failedTitle', 'Something went wrong')}
{t('gift.failedDesc', 'Your gift could not be processed. Please try again.')}
@@ -192,6 +210,55 @@ function FailedState() {
);
}
+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.',
+ )}
+
+
+
+ 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')}
+
+
+ );
+}
+
function PollTimedOutState({ onRetry }: { onRetry: () => void }) {
const { t } = useTranslation();
@@ -218,11 +285,11 @@ function PollTimedOutState({ onRetry }: { onRetry: () => void }) {
- {t('gift.pollTimedOut', 'Taking longer than expected')}
+ {t('gift.pollTimeout', 'Taking longer than expected')}
{t(
- 'gift.pollTimedOutDesc',
+ 'gift.pollTimeoutDesc',
'Payment processing is taking longer than usual. You can try checking again.',
)}
@@ -232,7 +299,7 @@ function PollTimedOutState({ onRetry }: { onRetry: () => void }) {
onClick={onRetry}
className="rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
>
- {t('common.retry', 'Retry')}
+ {t('gift.retry', 'Retry')}
);
@@ -264,9 +331,9 @@ function NoTokenState() {
-
{t('gift.invalidLink', 'Invalid link')}
+
{t('gift.noToken', 'Invalid link')}
- {t('gift.invalidLinkDesc', 'This gift link is invalid or has expired.')}
+ {t('gift.noTokenDesc', 'This gift link is invalid or has expired.')}
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.giftNewSubscription', 'Gift a subscription')}
+ {t('gift.backToGift', 'Go back')}
);
@@ -288,6 +355,8 @@ export default function GiftResult() {
const [searchParams] = useSearchParams();
const token = searchParams.get('token');
const mode = searchParams.get('mode');
+ const rawWarning = searchParams.get('warning');
+ const warning = rawWarning && KNOWN_WARNINGS.has(rawWarning) ? rawWarning : null;
const pollStart = useRef(Date.now());
const [pollTimedOut, setPollTimedOut] = useState(false);
@@ -307,7 +376,8 @@ export default function GiftResult() {
if (isBalanceMode) return false;
const s = query.state.data?.status;
- if (s === 'delivered' || s === 'failed' || s === 'pending_activation') return false;
+ if (s === 'delivered' || s === 'failed' || s === 'pending_activation' || s === 'expired')
+ return false;
// Check poll timeout
if (Date.now() - pollStart.current > MAX_POLL_MS) {
@@ -343,7 +413,7 @@ export default function GiftResult() {
const isDelivered = status?.status === 'delivered';
const isPendingActivation = status?.status === 'pending_activation';
- const isFailed = status?.status === 'failed';
+ const isFailed = status?.status === 'failed' || status?.status === 'expired';
return (
@@ -352,7 +422,9 @@ export default function GiftResult() {
aria-live="polite"
aria-atomic="true"
>
- {isError ? (
+ {isError && isBalanceMode ? (
+
+ ) : isError ? (
) : isDelivered ? (
) : isPendingActivation ? (
) : isFailed ? (
diff --git a/src/pages/GiftSubscription.tsx b/src/pages/GiftSubscription.tsx
index da459c0..b5d0336 100644
--- a/src/pages/GiftSubscription.tsx
+++ b/src/pages/GiftSubscription.tsx
@@ -27,8 +27,10 @@ function detectContactType(value: string): 'email' | 'telegram' {
function isValidContact(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) return false;
- if (trimmed.startsWith('@')) return trimmed.length >= 4;
- return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed);
+ 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(
@@ -82,7 +84,7 @@ function ErrorState({ message }: { message: string }) {
/>
- {t('gift.error', 'Error')}
+ {t('gift.failedTitle', 'Error')}
{message}
@@ -117,11 +119,9 @@ function DisabledState() {
- {t('gift.disabled', 'Gift subscriptions are currently unavailable')}
+ {t('gift.featureDisabled', 'Gift subscriptions are currently unavailable')}
-
- {t('gift.disabledRedirect', 'Redirecting to dashboard...')}
-
+ {t('gift.redirecting', 'Redirecting...')}
);
@@ -512,7 +512,7 @@ function GiftSummaryCard({
{selectedTariff && (
- {t('gift.selectedTariff', 'Tariff')}
+ {t('gift.tariff', 'Tariff')}
{selectedTariff.name}
@@ -577,7 +577,7 @@ function GiftSummaryCard({
to="/balance"
className="font-medium text-accent-400 underline underline-offset-2"
>
- {t('gift.topUp', 'Top up')}
+ {t('gift.topUpBalance', 'Top up balance')}
@@ -650,7 +650,6 @@ export default function GiftSubscription() {
const [paymentMode, setPaymentMode] = useState<'balance' | 'gateway'>('balance');
const [selectedMethod, setSelectedMethod] = useState(null);
const [selectedSubOption, setSelectedSubOption] = useState(null);
- const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState(null);
// Collect ALL unique periods across ALL tariffs
@@ -749,24 +748,27 @@ export default function GiftSubscription() {
// Balance mode - show success
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['gift-config'] });
- navigate('/gift/result?token=' + result.purchase_token + '&mode=balance');
+ 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.purchaseError', 'Something went wrong. Please try again.'),
+ t('gift.failedDesc', 'Something went wrong. Please try again.'),
);
setSubmitError(msg);
- setIsSubmitting(false);
},
});
// Submit handler
const handleSubmit = () => {
- if (!canSubmit || isSubmitting) return;
+ if (!selectedTariffId || !selectedPeriodDays || !canSubmit || purchaseMutation.isPending)
+ return;
- setIsSubmitting(true);
setSubmitError(null);
let paymentMethod: string | undefined;
@@ -778,8 +780,8 @@ export default function GiftSubscription() {
}
const data: GiftPurchaseRequest = {
- tariff_id: selectedTariffId!,
- period_days: selectedPeriodDays!,
+ tariff_id: selectedTariffId,
+ period_days: selectedPeriodDays,
recipient_type: detectContactType(recipientValue),
recipient_value: recipientValue.trim(),
gift_message: giftMessage.trim() || undefined,
@@ -885,7 +887,7 @@ export default function GiftSubscription() {
{/* Recipient */}
- {t('gift.recipientSection', 'Recipient')}
+ {t('gift.recipientLabel', 'Recipient')}
- {t('gift.messageSection', 'Personal message')}
+ {t('gift.giftMessageLabel', 'Message')}
@@ -907,7 +909,7 @@ export default function GiftSubscription() {
{/* Payment mode toggle */}
- {t('gift.paymentModeSection', 'Payment method')}
+ {t('gift.paymentMode', 'Payment method')}
Date: Mon, 9 Mar 2026 20:56:42 +0300
Subject: [PATCH 06/17] feat: add gift subscription toggle to admin branding
settings
Add CABINET_GIFT_ENABLED toggle in Admin Settings > Branding > Interface Options,
alongside existing fullscreen and email auth toggles. Includes updateGiftEnabled
API method and i18n keys for all 4 locales (ru/en/zh/fa).
---
src/api/branding.ts | 8 ++++++++
src/components/admin/BrandingTab.tsx | 24 ++++++++++++++++++++++++
src/locales/en.json | 2 ++
src/locales/fa.json | 2 ++
src/locales/ru.json | 2 ++
src/locales/zh.json | 2 ++
6 files changed, 40 insertions(+)
diff --git a/src/api/branding.ts b/src/api/branding.ts
index 5136814..748041d 100644
--- a/src/api/branding.ts
+++ b/src/api/branding.ts
@@ -265,6 +265,14 @@ export const brandingApi = {
}
},
+ // 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/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/locales/en.json b/src/locales/en.json
index ad055ea..e4f06db 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -1696,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",
diff --git a/src/locales/fa.json b/src/locales/fa.json
index f94bde1..19f79af 100644
--- a/src/locales/fa.json
+++ b/src/locales/fa.json
@@ -1367,6 +1367,8 @@
"darkTheme": "پوسته تیره",
"emailAuth": "احراز هویت ایمیل",
"emailAuthDesc": "اجازه ورود با ایمیل",
+ "giftEnabled": "اشتراک هدیه",
+ "giftEnabledDesc": "امکان ارسال اشتراک به عنوان هدیه به کاربر دیگر",
"favoritesEmpty": "هنوز علاقهمندی ندارید",
"favoritesHint": "ستاره بزنید تا تنظیمات اینجا اضافه شوند",
"interfaceOptions": "گزینههای رابط کاربری",
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 6abfecc..e787c60 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -1706,6 +1706,8 @@
"autoFullscreenDesc": "В Telegram WebApp",
"emailAuth": "Email авторизация",
"emailAuthDesc": "Регистрация и вход через email",
+ "giftEnabled": "Подписка в подарок",
+ "giftEnabledDesc": "Возможность отправить подписку в подарок другому пользователю",
"availableThemes": "Доступные темы",
"darkTheme": "Тёмная",
"lightTheme": "Светлая",
diff --git a/src/locales/zh.json b/src/locales/zh.json
index c7eb3b1..5a7bb5b 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -1405,6 +1405,8 @@
"darkTheme": "暗色主题",
"emailAuth": "邮箱认证",
"emailAuthDesc": "允许通过邮箱登录",
+ "giftEnabled": "赠送订阅",
+ "giftEnabledDesc": "允许用户将订阅作为礼物赠送给其他用户",
"favoritesEmpty": "暂无收藏",
"favoritesHint": "点击星标将设置添加到这里",
"interfaceOptions": "界面选项",
From c8ec2211112656ffc3787e905d9d6b2774bc6866 Mon Sep 17 00:00:00 2001
From: Fringg
Date: Mon, 9 Mar 2026 21:03:05 +0300
Subject: [PATCH 07/17] fix: remove bg-dark-950 from gift pages to preserve
animated background
---
src/pages/GiftResult.tsx | 4 ++--
src/pages/GiftSubscription.tsx | 8 ++++----
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/src/pages/GiftResult.tsx b/src/pages/GiftResult.tsx
index 7083d60..191ff5a 100644
--- a/src/pages/GiftResult.tsx
+++ b/src/pages/GiftResult.tsx
@@ -399,7 +399,7 @@ export default function GiftResult() {
// No token
if (!token) {
return (
-
+
+
+
@@ -67,7 +67,7 @@ function ErrorState({ message }: { message: string }) {
const { t } = useTranslation();
return (
-
+
+
1;
return (
-
+
{/* Header */}
Date: Mon, 9 Mar 2026 21:05:36 +0300
Subject: [PATCH 08/17] fix: make desktop nav horizontally scrollable on narrow
screens
---
src/components/layout/AppShell/AppShell.tsx | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/src/components/layout/AppShell/AppShell.tsx b/src/components/layout/AppShell/AppShell.tsx
index 898bc97..24f8a5f 100644
--- a/src/components/layout/AppShell/AppShell.tsx
+++ b/src/components/layout/AppShell/AppShell.tsx
@@ -317,14 +317,14 @@ export function AppShell({ children }: AppShellProps) {
{/* Center Navigation */}
-
+
{desktopNavItems.map((item) => (
{/* Separator before admin */}
-
+
Date: Mon, 9 Mar 2026 21:09:37 +0300
Subject: [PATCH 09/17] feat: add gradient fade indicators to scrollable
desktop nav
---
src/components/layout/AppShell/AppShell.tsx | 163 ++++++++++++--------
1 file changed, 102 insertions(+), 61 deletions(-)
diff --git a/src/components/layout/AppShell/AppShell.tsx b/src/components/layout/AppShell/AppShell.tsx
index 24f8a5f..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';
@@ -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,73 +342,89 @@ export function AppShell({ children }: AppShellProps) {
{/* Center Navigation */}
-
- {desktopNavItems.map((item) => (
-
-
- {item.label}
-
- ))}
- {referralEnabled && (
-
-
- {t('nav.referral')}
-
- )}
- {giftEnabled && (
-
-
- {t('nav.gift')}
-
- )}
- {isAdmin && (
- <>
- {/* Separator before admin */}
-
+
+ {/* Left fade */}
+
+ {/* Right fade */}
+
+
+ {desktopNavItems.map((item) => (
-
- {t('admin.nav.title')}
+
+ {item.label}
- >
- )}
-
+ ))}
+ {referralEnabled && (
+
+
+
{t('nav.referral')}
+
+ )}
+ {giftEnabled && (
+
+
+
{t('nav.gift')}
+
+ )}
+ {isAdmin && (
+ <>
+ {/* Separator before admin */}
+
+
+
+
{t('admin.nav.title')}
+
+ >
+ )}
+
+
{/* Right side actions */}
From 4322d58ff8ca56ba401b669370bee8783cf55a86 Mon Sep 17 00:00:00 2001
From: Fringg
Date: Mon, 9 Mar 2026 21:26:02 +0300
Subject: [PATCH 10/17] feat: read gift warning from status response, soften
poll error state
- Add warning field to GiftPurchaseStatus type
- Read warning from status response with URL param fallback
- Show PollErrorState for all poll errors (softer UX for gateway payments)
---
src/api/gift.ts | 1 +
src/pages/GiftResult.tsx | 13 ++++++++-----
2 files changed, 9 insertions(+), 5 deletions(-)
diff --git a/src/api/gift.ts b/src/api/gift.ts
index a4fa67a..c96c180 100644
--- a/src/api/gift.ts
+++ b/src/api/gift.ts
@@ -74,6 +74,7 @@ export interface GiftPurchaseStatus {
gift_message: string | null;
tariff_name: string | null;
period_days: number | null;
+ warning: string | null;
}
export interface PendingGift {
diff --git a/src/pages/GiftResult.tsx b/src/pages/GiftResult.tsx
index 191ff5a..6193e16 100644
--- a/src/pages/GiftResult.tsx
+++ b/src/pages/GiftResult.tsx
@@ -355,8 +355,8 @@ export default function GiftResult() {
const [searchParams] = useSearchParams();
const token = searchParams.get('token');
const mode = searchParams.get('mode');
- const rawWarning = searchParams.get('warning');
- const warning = rawWarning && KNOWN_WARNINGS.has(rawWarning) ? rawWarning : null;
+ const rawUrlWarning = searchParams.get('warning');
+ const urlWarning = rawUrlWarning && KNOWN_WARNINGS.has(rawUrlWarning) ? rawUrlWarning : null;
const pollStart = useRef(Date.now());
const [pollTimedOut, setPollTimedOut] = useState(false);
@@ -415,6 +415,11 @@ export default function GiftResult() {
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 && isBalanceMode ? (
+ {isError ? (
- ) : isError ? (
-
) : isDelivered ? (
Date: Mon, 9 Mar 2026 21:53:55 +0300
Subject: [PATCH 11/17] fix: remove noreferrer from payment links to preserve
Referer header
WATA DDoS Guard blocks requests without Referer (403 error).
Removed noreferrer from window.open and tags on payment URLs.
noopener alone is sufficient for security (prevents window.opener access).
---
src/components/blocking/ChannelSubscriptionScreen.tsx | 2 +-
src/pages/AdminPayments.tsx | 2 +-
src/platform/adapters/WebAdapter.ts | 4 ++--
3 files changed, 4 insertions(+), 4 deletions(-)
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/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/platform/adapters/WebAdapter.ts b/src/platform/adapters/WebAdapter.ts
index a75667a..0e90ff4 100644
--- a/src/platform/adapters/WebAdapter.ts
+++ b/src/platform/adapters/WebAdapter.ts
@@ -203,11 +203,11 @@ export function createWebAdapter(): PlatformContext {
},
openLink(url: string, _options?: { tryInstantView?: boolean }) {
- window.open(url, '_blank', 'noopener,noreferrer');
+ window.open(url, '_blank', 'noopener');
},
openTelegramLink(url: string) {
- window.open(url, '_blank', 'noopener,noreferrer');
+ window.open(url, '_blank', 'noopener');
},
async share(text: string, url?: string): Promise {
From dc740ae2664059011fd755ccfa96ee46a26196d3 Mon Sep 17 00:00:00 2001
From: Fringg
Date: Mon, 9 Mar 2026 21:58:46 +0300
Subject: [PATCH 12/17] fix: restore session from refresh token when access
token is missing
When access token is absent from sessionStorage (tab reopen, page refresh)
but refresh token exists in localStorage, the request interceptor now
attempts token refresh before sending the request. Previously it would
send the request without Authorization header, causing 401 errors on
endpoints like /cabinet/auth/email/register.
---
src/api/client.ts | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
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) {
From 9c7ab4b789f0d2e92c81afd2199789d03d3768db Mon Sep 17 00:00:00 2001
From: Fringg
Date: Mon, 9 Mar 2026 22:08:43 +0300
Subject: [PATCH 13/17] fix: admin promo groups - add default toggle, fix
threshold reset to 0
- Added is_default toggle to promo group create/edit form
- Send 0 instead of null when clearing auto-assign threshold
- Added isDefault i18n keys for ru, en, zh, fa
---
src/locales/en.json | 1 +
src/locales/fa.json | 1 +
src/locales/ru.json | 1 +
src/locales/zh.json | 1 +
src/pages/AdminPromoGroupCreate.tsx | 23 ++++++++++++++++++++++-
5 files changed, 26 insertions(+), 1 deletion(-)
diff --git a/src/locales/en.json b/src/locales/en.json
index e4f06db..7f1c18e 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -2538,6 +2538,7 @@
"rub": "rub.",
"autoAssignHint": "0 = don't auto-assign",
"applyToAddons": "Apply to additional services",
+ "isDefault": "Default group",
"cancel": "Cancel",
"saving": "Saving...",
"save": "Save"
diff --git a/src/locales/fa.json b/src/locales/fa.json
index 19f79af..1458a80 100644
--- a/src/locales/fa.json
+++ b/src/locales/fa.json
@@ -2200,6 +2200,7 @@
"rub": "روبل",
"autoAssignHint": "0 = تخصیص خودکار نشود",
"applyToAddons": "اعمال به خدمات اضافی",
+ "isDefault": "گروه پیشفرض",
"cancel": "انصراف",
"saving": "در حال ذخیره...",
"save": "ذخیره"
diff --git a/src/locales/ru.json b/src/locales/ru.json
index e787c60..4655a71 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -3059,6 +3059,7 @@
"rub": "руб.",
"autoAssignHint": "0 = не назначать автоматически",
"applyToAddons": "Применять к дополнительным услугам",
+ "isDefault": "Группа по умолчанию",
"cancel": "Отмена",
"saving": "Сохранение...",
"save": "Сохранить"
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 5a7bb5b..76c17d1 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -2199,6 +2199,7 @@
"rub": "卢布",
"autoAssignHint": "0 = 不自动分配",
"applyToAddons": "应用于附加服务",
+ "isDefault": "默认组",
"cancel": "取消",
"saving": "保存中...",
"save": "保存"
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 */}
+
+ setIsDefault(!isDefault)}
+ className={`relative h-6 w-11 rounded-full transition-colors ${
+ isDefault ? 'bg-accent-500' : 'bg-dark-600'
+ }`}
+ >
+
+
+ {t('admin.promoGroups.form.isDefault')}
+
{/* Footer */}
From 78fda22679b9f5b4443fa602214e28ad52f7f2e9 Mon Sep 17 00:00:00 2001
From: Fringg
Date: Mon, 9 Mar 2026 22:16:37 +0300
Subject: [PATCH 14/17] fix: add missing nameRequired i18n key for promo group
form validation
---
src/locales/en.json | 1 +
src/locales/fa.json | 1 +
src/locales/ru.json | 1 +
src/locales/zh.json | 1 +
4 files changed, 4 insertions(+)
diff --git a/src/locales/en.json b/src/locales/en.json
index 7f1c18e..27a0f5e 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -2527,6 +2527,7 @@
"form": {
"name": "Group name",
"namePlaceholder": "VIP customers",
+ "nameRequired": "Enter group name",
"categoryDiscounts": "Category discounts",
"periodDiscounts": "Period discounts",
"add": "Add",
diff --git a/src/locales/fa.json b/src/locales/fa.json
index 1458a80..2004e50 100644
--- a/src/locales/fa.json
+++ b/src/locales/fa.json
@@ -2189,6 +2189,7 @@
"form": {
"name": "نام گروه",
"namePlaceholder": "مشتریان VIP",
+ "nameRequired": "نام گروه را وارد کنید",
"categoryDiscounts": "تخفیفهای دستهبندی",
"periodDiscounts": "تخفیفهای دورهای",
"add": "افزودن",
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 4655a71..0340eb4 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -3048,6 +3048,7 @@
"form": {
"name": "Название группы",
"namePlaceholder": "VIP клиенты",
+ "nameRequired": "Введите название группы",
"categoryDiscounts": "Скидки по категориям",
"periodDiscounts": "Скидки по периодам",
"add": "Добавить",
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 76c17d1..99a20ff 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -2188,6 +2188,7 @@
"form": {
"name": "组名称",
"namePlaceholder": "VIP客户",
+ "nameRequired": "请输入组名称",
"categoryDiscounts": "分类折扣",
"periodDiscounts": "期间折扣",
"add": "添加",
From 23aa86f1a81556ce2083e8b86107ee1a82c429b1 Mon Sep 17 00:00:00 2001
From: Fringg
Date: Mon, 9 Mar 2026 23:07:38 +0300
Subject: [PATCH 15/17] feat: add menu editor tab with drag-and-drop rows,
custom URL buttons, and button configuration
- Add menuLayout.ts API client with normalizeConfig and type definitions
- Add MenuEditorTab.tsx with DnD rows, button CRUD, style/emoji/label editors
- Replace ButtonsTab with MenuEditorTab on admin settings page
- Add menu editor translations for ru, en, zh, fa locales
- Loading/error states, useMemo optimizations, proper ref patterns
---
src/api/menuLayout.ts | 88 +++
src/components/admin/MenuEditorTab.tsx | 816 +++++++++++++++++++++++++
src/locales/en.json | 19 +-
src/locales/fa.json | 19 +-
src/locales/ru.json | 19 +-
src/locales/zh.json | 19 +-
src/pages/AdminSettings.tsx | 4 +-
7 files changed, 974 insertions(+), 10 deletions(-)
create mode 100644 src/api/menuLayout.ts
create mode 100644 src/components/admin/MenuEditorTab.tsx
diff --git a/src/api/menuLayout.ts b/src/api/menuLayout.ts
new file mode 100644
index 0000000..40320d1
--- /dev/null
+++ b/src/api/menuLayout.ts
@@ -0,0 +1,88 @@
+import apiClient from './client';
+
+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;
+}
+
+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,
+};
+
+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/MenuEditorTab.tsx b/src/components/admin/MenuEditorTab.tsx
new file mode 100644
index 0000000..c40359d
--- /dev/null
+++ b/src/components/admin/MenuEditorTab.tsx
@@ -0,0 +1,816 @@
+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 = () => (
+
+
+
+);
+
+// ============ 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) => (
+ 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}
+
+ ))}
+
+ );
+}
+
+// ============ ButtonChip ============
+
+interface ButtonChipProps {
+ button: MenuButtonConfig;
+ isExpanded: boolean;
+ onToggleExpand: () => void;
+ onUpdate: (updates: Partial) => void;
+ onRemove: () => void;
+ isBuiltin: boolean;
+}
+
+function ButtonChip({
+ button,
+ isExpanded,
+ onToggleExpand,
+ onUpdate,
+ onRemove,
+ 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 */}
+
+
+ {t('admin.buttons.color')}
+
+
+ {STYLE_OPTIONS.map((opt) => (
+ 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'
+ }`}
+ >
+
+ {t(`admin.buttons.styles.${opt.value}`)}
+
+ ))}
+
+
+
+ {/* Emoji ID */}
+
+
+ {t('admin.buttons.emojiId')}
+
+ 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 (custom buttons only) */}
+ {!isBuiltin && (
+
+ URL
+ 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"
+ />
+
+ )}
+
+ {/* Localized labels */}
+
+
+ {t('admin.buttons.customLabels')}
+
+
+ {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;
+ 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;
+ onAddButtonClick: (rowId: string) => void;
+}
+
+function SortableRow({
+ row,
+ rowIndex,
+ expandedButtons,
+ onToggleExpand,
+ onUpdateRow,
+ onRemoveRow,
+ onUpdateButton,
+ onRemoveButton,
+ onAddButtonClick,
+}: 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 && (
+ onRemoveRow(row.id)}
+ className="rounded-lg p-1.5 text-dark-500 transition-colors hover:bg-red-500/10 hover:text-red-400"
+ >
+
+
+ )}
+
+
+ {/* Row body */}
+
+ {row.buttons.map((button) => (
+
onToggleExpand(button.id)}
+ onUpdate={(updates) => onUpdateButton(row.id, button.id, updates)}
+ onRemove={() => onRemoveButton(row.id, button.id)}
+ isBuiltin={button.type === 'builtin'}
+ />
+ ))}
+
+ {/* Add button */}
+ onAddButtonClick(row.id)}
+ 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"
+ >
+
+ {t('admin.menuEditor.addButton')}
+
+
+
+ );
+}
+
+// ============ AddButtonDialog ============
+
+interface AddButtonDialogProps {
+ usedBuiltinIds: Set;
+ onAddBuiltin: (sectionId: string) => void;
+ onAddCustom: () => void;
+ onClose: () => void;
+}
+
+function AddButtonDialog({
+ usedBuiltinIds,
+ onAddBuiltin,
+ onAddCustom,
+ onClose,
+}: AddButtonDialogProps) {
+ const { t } = useTranslation();
+
+ const availableBuiltins = BUILTIN_SECTIONS.filter((id) => !usedBuiltinIds.has(id));
+
+ return (
+
+
e.stopPropagation()}
+ >
+
+
{t('admin.menuEditor.addButton')}
+
+
+
+
+
+
+
+
+ {/* Available builtins */}
+ {availableBuiltins.length > 0 && (
+ <>
+
+ {t('admin.menuEditor.builtinButtons')}
+
+ {availableBuiltins.map((id) => (
+
{
+ onAddBuiltin(id);
+ onClose();
+ }}
+ className="flex w-full items-center gap-2 rounded-xl px-3 py-2.5 text-left text-sm text-dark-200 transition-colors hover:bg-dark-700/50"
+ >
+ {t(`admin.buttons.sections.${id}`)}
+
+ ))}
+
+ >
+ )}
+
+ {/* Custom URL button */}
+
{
+ onAddCustom();
+ onClose();
+ }}
+ className="flex w-full items-center gap-2 rounded-xl px-3 py-2.5 text-left text-sm text-dark-200 transition-colors hover:bg-dark-700/50"
+ >
+
+ {t('admin.menuEditor.addUrlButton')}
+
+
+
+
+ );
+}
+
+// ============ 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 [addingToRow, setAddingToRow] = useState(null);
+ 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,
+ };
+ 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: '',
+ };
+ setDraftConfig((prev) => ({
+ ...prev,
+ rows: prev.rows.map((r) =>
+ r.id === rowId ? { ...r, buttons: [...r.buttons, newButton] } : r,
+ ),
+ }));
+ }, []);
+
+ const handleAddButtonClick = useCallback((rowId: string) => {
+ setAddingToRow(rowId);
+ }, []);
+
+ // 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 */}
+
+
+ {t('admin.menuEditor.addRow')}
+
+
+ {/* Save / Cancel */}
+ {hasUnsavedChanges && (
+
+
+ {updateMutation.isPending ? t('common.saving') : t('common.save')}
+
+
+ {t('common.cancel')}
+
+
+ )}
+
+ {/* Reset */}
+
+ {
+ 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')}
+
+
+
+ {/* Add button dialog */}
+ {addingToRow && (
+
addBuiltinButton(addingToRow, sectionId)}
+ onAddCustom={() => addCustomButton(addingToRow)}
+ onClose={() => setAddingToRow(null)}
+ />
+ )}
+
+ );
+}
diff --git a/src/locales/en.json b/src/locales/en.json
index 27a0f5e..fcbb9bc 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -1748,7 +1748,8 @@
"referral": "Referrals",
"support": "Support",
"info": "Info",
- "admin": "Admin Panel"
+ "admin": "Admin Panel",
+ "language": "Language"
},
"descriptions": {
"home": "Personal cabinet button",
@@ -1757,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",
@@ -1766,6 +1768,19 @@
"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",
+ "invalidUrl": "Please provide a valid URL (http:// or https://) for all custom buttons"
+ },
"apps": {
"title": "App Management",
"addApp": "Add App",
diff --git a/src/locales/fa.json b/src/locales/fa.json
index 2004e50..2d335d9 100644
--- a/src/locales/fa.json
+++ b/src/locales/fa.json
@@ -1410,7 +1410,8 @@
"referral": "معرفی",
"support": "پشتیبانی",
"info": "اطلاعات",
- "admin": "پنل مدیریت"
+ "admin": "پنل مدیریت",
+ "language": "زبان"
},
"descriptions": {
"home": "دکمه کابینت شخصی",
@@ -1419,7 +1420,8 @@
"referral": "برنامه معرفی",
"support": "تیکتهای پشتیبانی",
"info": "بخش اطلاعات",
- "admin": "پنل مدیریت وب"
+ "admin": "پنل مدیریت وب",
+ "language": "انتخاب زبان ربات"
},
"styles": {
"default": "بدون رنگ",
@@ -1428,6 +1430,19 @@
"danger": "قرمز"
}
},
+ "menuEditor": {
+ "dragHint": "ردیفها را بکشید تا ترتیب تغییر کند",
+ "dragToReorder": "برای مرتبسازی بکشید",
+ "row": "ردیف",
+ "addRow": "افزودن ردیف",
+ "addButton": "افزودن دکمه",
+ "addUrlButton": "دکمه URL (لینک)",
+ "builtinButtons": "بخشهای داخلی",
+ "resetConfirm": "منو به تنظیمات پیشفرض بازنشانی شود؟ تمام تغییرات از بین میرود.",
+ "buttonTextPlaceholder": "متن دکمه",
+ "customLabelsHint": "متن دکمه برای هر زبان ربات",
+ "invalidUrl": "لطفاً برای تمام دکمههای سفارشی یک URL معتبر وارد کنید (http:// یا https://)"
+ },
"apps": {
"title": "مدیریت برنامهها",
"addApp": "افزودن برنامه",
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 0340eb4..85aa4a5 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -2259,7 +2259,8 @@
"referral": "Рефералы",
"support": "Поддержка",
"info": "Информация",
- "admin": "Админка"
+ "admin": "Админка",
+ "language": "Язык"
},
"descriptions": {
"home": "Кнопка личного кабинета",
@@ -2268,7 +2269,8 @@
"referral": "Реферальная программа",
"support": "Обращения в поддержку",
"info": "Информационный раздел",
- "admin": "Веб-админка"
+ "admin": "Веб-админка",
+ "language": "Выбор языка"
},
"styles": {
"default": "Без цвета",
@@ -2277,6 +2279,19 @@
"danger": "Красный"
}
},
+ "menuEditor": {
+ "dragHint": "Перетаскивайте ряды для изменения порядка",
+ "dragToReorder": "Перетащите для сортировки",
+ "row": "Ряд",
+ "addRow": "Добавить ряд",
+ "addButton": "Добавить кнопку",
+ "addUrlButton": "URL-кнопка (ссылка)",
+ "builtinButtons": "Встроенные кнопки",
+ "resetConfirm": "Сбросить компоновку меню к значениям по умолчанию?",
+ "buttonTextPlaceholder": "Свой текст кнопки",
+ "customLabelsHint": "Пустое поле = название по умолчанию",
+ "invalidUrl": "Укажите корректный URL (http:// или https://) для всех пользовательских кнопок"
+ },
"apps": {
"title": "Управление приложениями",
"addApp": "Добавить приложение",
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 99a20ff..53a7eba 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -1448,7 +1448,8 @@
"referral": "推荐",
"support": "支持",
"info": "信息",
- "admin": "管理面板"
+ "admin": "管理面板",
+ "language": "语言"
},
"descriptions": {
"home": "个人中心按钮",
@@ -1457,7 +1458,8 @@
"referral": "推荐计划",
"support": "支持工单",
"info": "信息板块",
- "admin": "网页管理面板"
+ "admin": "网页管理面板",
+ "language": "机器人语言选择"
},
"styles": {
"default": "无颜色",
@@ -1466,6 +1468,19 @@
"danger": "红色"
}
},
+ "menuEditor": {
+ "dragHint": "拖拽行以重新排序",
+ "dragToReorder": "拖拽排序",
+ "row": "行",
+ "addRow": "添加行",
+ "addButton": "添加按钮",
+ "addUrlButton": "URL按钮(链接)",
+ "builtinButtons": "内置栏目",
+ "resetConfirm": "将菜单重置为默认设置?所有更改将丢失。",
+ "buttonTextPlaceholder": "按钮文本",
+ "customLabelsHint": "每种语言的按钮文本",
+ "invalidUrl": "请为所有自定义按钮提供有效的URL(http:// 或 https://)"
+ },
"apps": {
"title": "应用管理",
"addApp": "添加应用",
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 (
Date: Mon, 9 Mar 2026 23:26:16 +0300
Subject: [PATCH 16/17] feat: add button reordering within rows and replace
modal with inline add panel
- Add up/down arrow buttons to reorder buttons within rows (builtin + custom)
- Replace AddButtonDialog modal (bg-black/50 overlay) with inline expandable panel
- Add moveUp/moveDown i18n keys for all locales (en/ru/zh/fa)
- Add aria-labels for accessibility on reorder buttons
---
src/components/admin/MenuEditorTab.tsx | 247 +++++++++++++++----------
src/locales/en.json | 2 +
src/locales/fa.json | 2 +
src/locales/ru.json | 2 +
src/locales/zh.json | 2 +
5 files changed, 157 insertions(+), 98 deletions(-)
diff --git a/src/components/admin/MenuEditorTab.tsx b/src/components/admin/MenuEditorTab.tsx
index c40359d..d157d3a 100644
--- a/src/components/admin/MenuEditorTab.tsx
+++ b/src/components/admin/MenuEditorTab.tsx
@@ -86,6 +86,30 @@ const LinkIcon = () => (
);
+const ArrowUpIcon = () => (
+
+
+
+);
+
+const ArrowDownIcon = () => (
+
+
+
+);
+
// ============ Helpers ============
function generateId(): string {
@@ -137,6 +161,8 @@ interface ButtonChipProps {
onToggleExpand: () => void;
onUpdate: (updates: Partial) => void;
onRemove: () => void;
+ onMoveUp: (() => void) | null;
+ onMoveDown: (() => void) | null;
isBuiltin: boolean;
}
@@ -146,6 +172,8 @@ function ButtonChip({
onToggleExpand,
onUpdate,
onRemove,
+ onMoveUp,
+ onMoveDown,
isBuiltin,
}: ButtonChipProps) {
const { t } = useTranslation();
@@ -168,6 +196,32 @@ function ButtonChip({
>
{/* Collapsed header */}
+
{displayName}
@@ -288,24 +342,30 @@ 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;
- onAddButtonClick: (rowId: 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,
- onAddButtonClick,
+ onAddBuiltin,
+ onAddCustom,
+ onReorderButton,
}: SortableRowProps) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
@@ -359,7 +419,7 @@ function SortableRow({
{/* Row body */}
- {row.buttons.map((button) => (
+ {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'}
/>
))}
- {/* Add button */}
- onAddButtonClick(row.id)}
- 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"
- >
-
- {t('admin.menuEditor.addButton')}
-
+ {/* Inline add button panel */}
+
);
}
-// ============ AddButtonDialog ============
+// ============ InlineAddPanel ============
-interface AddButtonDialogProps {
+interface InlineAddPanelProps {
+ rowId: string;
usedBuiltinIds: Set;
- onAddBuiltin: (sectionId: string) => void;
- onAddCustom: () => void;
- onClose: () => void;
+ onAddBuiltin: (rowId: string, sectionId: string) => void;
+ onAddCustom: (rowId: string) => void;
}
-function AddButtonDialog({
- usedBuiltinIds,
- onAddBuiltin,
- onAddCustom,
- onClose,
-}: AddButtonDialogProps) {
+function InlineAddPanel({ rowId, usedBuiltinIds, onAddBuiltin, onAddCustom }: InlineAddPanelProps) {
const { t } = useTranslation();
+ const [isOpen, setIsOpen] = useState(false);
const availableBuiltins = BUILTIN_SECTIONS.filter((id) => !usedBuiltinIds.has(id));
- return (
-
-
e.stopPropagation()}
+ if (!isOpen) {
+ return (
+
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"
>
-
-
{t('admin.menuEditor.addButton')}
-
-
+ {t('admin.menuEditor.addButton')}
+
+ );
+ }
+
+ return (
+
+ {availableBuiltins.length > 0 && (
+ <>
+
+ {t('admin.menuEditor.builtinButtons')}
+
+ {availableBuiltins.map((id) => (
+
{
+ 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"
>
-
-
-
-
-
-
- {/* Available builtins */}
- {availableBuiltins.length > 0 && (
- <>
-
- {t('admin.menuEditor.builtinButtons')}
-
- {availableBuiltins.map((id) => (
-
{
- onAddBuiltin(id);
- onClose();
- }}
- className="flex w-full items-center gap-2 rounded-xl px-3 py-2.5 text-left text-sm text-dark-200 transition-colors hover:bg-dark-700/50"
- >
- {t(`admin.buttons.sections.${id}`)}
-
- ))}
-
- >
- )}
-
- {/* Custom URL button */}
-
{
- onAddCustom();
- onClose();
- }}
- className="flex w-full items-center gap-2 rounded-xl px-3 py-2.5 text-left text-sm text-dark-200 transition-colors hover:bg-dark-700/50"
- >
-
- {t('admin.menuEditor.addUrlButton')}
-
-
-
+ {t(`admin.buttons.sections.${id}`)}
+
+ ))}
+
+ >
+ )}
+
{
+ 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"
+ >
+
+ {t('admin.menuEditor.addUrlButton')}
+
+
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')}
+
);
}
@@ -490,7 +538,6 @@ export function MenuEditorTab() {
// Draft state
const [draftConfig, setDraftConfig] = useState
(DEFAULT_CONFIG);
const [expandedButtons, setExpandedButtons] = useState>(new Set());
- const [addingToRow, setAddingToRow] = useState(null);
const savedConfigRef = useRef(DEFAULT_CONFIG);
const draftConfigRef = useRef(draftConfig);
draftConfigRef.current = draftConfig;
@@ -645,9 +692,20 @@ export function MenuEditorTab() {
}));
}, []);
- const handleAddButtonClick = useCallback((rowId: string) => {
- setAddingToRow(rowId);
- }, []);
+ 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) => {
@@ -746,12 +804,15 @@ export function MenuEditorTab() {
row={row}
rowIndex={index}
expandedButtons={expandedButtons}
+ usedBuiltinIds={usedBuiltinIds}
onToggleExpand={toggleExpand}
onUpdateRow={updateRow}
onRemoveRow={removeRow}
onUpdateButton={updateButton}
onRemoveButton={removeButton}
- onAddButtonClick={handleAddButtonClick}
+ onAddBuiltin={addBuiltinButton}
+ onAddCustom={addCustomButton}
+ onReorderButton={reorderButton}
/>
))}
@@ -801,16 +862,6 @@ export function MenuEditorTab() {
{t('admin.buttons.resetAll')}
-
- {/* Add button dialog */}
- {addingToRow && (
- addBuiltinButton(addingToRow, sectionId)}
- onAddCustom={() => addCustomButton(addingToRow)}
- onClose={() => setAddingToRow(null)}
- />
- )}
);
}
diff --git a/src/locales/en.json b/src/locales/en.json
index fcbb9bc..d89c9a0 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -1779,6 +1779,8 @@
"resetConfirm": "Reset menu layout to defaults?",
"buttonTextPlaceholder": "Custom button text",
"customLabelsHint": "Empty = default label",
+ "moveUp": "Move up",
+ "moveDown": "Move down",
"invalidUrl": "Please provide a valid URL (http:// or https://) for all custom buttons"
},
"apps": {
diff --git a/src/locales/fa.json b/src/locales/fa.json
index 2d335d9..43c3f91 100644
--- a/src/locales/fa.json
+++ b/src/locales/fa.json
@@ -1441,6 +1441,8 @@
"resetConfirm": "منو به تنظیمات پیشفرض بازنشانی شود؟ تمام تغییرات از بین میرود.",
"buttonTextPlaceholder": "متن دکمه",
"customLabelsHint": "متن دکمه برای هر زبان ربات",
+ "moveUp": "انتقال به بالا",
+ "moveDown": "انتقال به پایین",
"invalidUrl": "لطفاً برای تمام دکمههای سفارشی یک URL معتبر وارد کنید (http:// یا https://)"
},
"apps": {
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 85aa4a5..39b1611 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -2290,6 +2290,8 @@
"resetConfirm": "Сбросить компоновку меню к значениям по умолчанию?",
"buttonTextPlaceholder": "Свой текст кнопки",
"customLabelsHint": "Пустое поле = название по умолчанию",
+ "moveUp": "Переместить вверх",
+ "moveDown": "Переместить вниз",
"invalidUrl": "Укажите корректный URL (http:// или https://) для всех пользовательских кнопок"
},
"apps": {
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 53a7eba..1edb919 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -1479,6 +1479,8 @@
"resetConfirm": "将菜单重置为默认设置?所有更改将丢失。",
"buttonTextPlaceholder": "按钮文本",
"customLabelsHint": "每种语言的按钮文本",
+ "moveUp": "上移",
+ "moveDown": "下移",
"invalidUrl": "请为所有自定义按钮提供有效的URL(http:// 或 https://)"
},
"apps": {
From 638844ef47686f4c9540b5591d499255cdc8ff2f Mon Sep 17 00:00:00 2001
From: Fringg
Date: Mon, 9 Mar 2026 23:33:05 +0300
Subject: [PATCH 17/17] feat: add open_in setting for custom buttons (external
browser / Telegram miniapp)
---
src/api/menuLayout.ts | 4 +++
src/components/admin/MenuEditorTab.tsx | 46 ++++++++++++++++++++------
src/locales/en.json | 5 +++
src/locales/fa.json | 5 +++
src/locales/ru.json | 5 +++
src/locales/zh.json | 5 +++
6 files changed, 59 insertions(+), 11 deletions(-)
diff --git a/src/api/menuLayout.ts b/src/api/menuLayout.ts
index 40320d1..148367f 100644
--- a/src/api/menuLayout.ts
+++ b/src/api/menuLayout.ts
@@ -1,5 +1,7 @@
import apiClient from './client';
+export type OpenIn = 'external' | 'webapp';
+
export interface MenuButtonConfig {
id: string;
type: 'builtin' | 'custom';
@@ -8,6 +10,7 @@ export interface MenuButtonConfig {
enabled: boolean;
labels: Record;
url: string | null;
+ open_in: OpenIn;
}
export interface MenuRowConfig {
@@ -51,6 +54,7 @@ const DEFAULT_BUTTON: Omit = {
enabled: true,
labels: {},
url: null,
+ open_in: 'external',
};
function normalizeConfig(data: MenuConfig): MenuConfig {
diff --git a/src/components/admin/MenuEditorTab.tsx b/src/components/admin/MenuEditorTab.tsx
index d157d3a..0daa1dc 100644
--- a/src/components/admin/MenuEditorTab.tsx
+++ b/src/components/admin/MenuEditorTab.tsx
@@ -288,18 +288,40 @@ function ButtonChip({
/>
- {/* URL input (custom buttons only) */}
+ {/* URL input + open mode (custom buttons only) */}
{!isBuiltin && (
-
- URL
- 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"
- />
-
+ <>
+
+ URL
+ 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"
+ />
+
+
+
+ {t('admin.menuEditor.openIn')}
+
+
+ {(['external', 'webapp'] as const).map((mode) => (
+ 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}`)}
+
+ ))}
+
+
+ >
)}
{/* Localized labels */}
@@ -665,6 +687,7 @@ export function MenuEditorTab() {
enabled: true,
labels: {},
url: null,
+ open_in: 'external',
};
setDraftConfig((prev) => ({
...prev,
@@ -683,6 +706,7 @@ export function MenuEditorTab() {
enabled: true,
labels: {},
url: '',
+ open_in: 'external',
};
setDraftConfig((prev) => ({
...prev,
diff --git a/src/locales/en.json b/src/locales/en.json
index d89c9a0..247732c 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -1781,6 +1781,11 @@
"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": {
diff --git a/src/locales/fa.json b/src/locales/fa.json
index 43c3f91..de94769 100644
--- a/src/locales/fa.json
+++ b/src/locales/fa.json
@@ -1443,6 +1443,11 @@
"customLabelsHint": "متن دکمه برای هر زبان ربات",
"moveUp": "انتقال به بالا",
"moveDown": "انتقال به پایین",
+ "openIn": "باز کردن در",
+ "openMode": {
+ "external": "مرورگر خارجی",
+ "webapp": "مینیاپ تلگرام"
+ },
"invalidUrl": "لطفاً برای تمام دکمههای سفارشی یک URL معتبر وارد کنید (http:// یا https://)"
},
"apps": {
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 39b1611..213ad6c 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -2292,6 +2292,11 @@
"customLabelsHint": "Пустое поле = название по умолчанию",
"moveUp": "Переместить вверх",
"moveDown": "Переместить вниз",
+ "openIn": "Открывать в",
+ "openMode": {
+ "external": "Внешний браузер",
+ "webapp": "Telegram miniapp"
+ },
"invalidUrl": "Укажите корректный URL (http:// или https://) для всех пользовательских кнопок"
},
"apps": {
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 1edb919..c0b7307 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -1481,6 +1481,11 @@
"customLabelsHint": "每种语言的按钮文本",
"moveUp": "上移",
"moveDown": "下移",
+ "openIn": "打开方式",
+ "openMode": {
+ "external": "外部浏览器",
+ "webapp": "Telegram 小程序"
+ },
"invalidUrl": "请为所有自定义按钮提供有效的URL(http:// 或 https://)"
},
"apps": {