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

+ )} +
+ + +
+ ); +} + +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.', + )} +

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

+
+ + +
+ ); +} + +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.', + )} +

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

+
+ +
+ ); +} + +// ============================================================ +// 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) => ( + + ))} +
+ ); +} + +function TariffCard({ + tariff, + isSelected, + selectedPeriod, + onSelect, +}: { + tariff: GiftTariff; + isSelected: boolean; + selectedPeriod: GiftTariffPeriod | undefined; + onSelect: () => void; +}) { + const { t } = useTranslation(); + + return ( + + ); +} + +function PaymentModeToggle({ + mode, + onToggle, + balanceLabel, +}: { + mode: 'balance' | 'gateway'; + onToggle: (mode: 'balance' | 'gateway') => void; + balanceLabel: string; +}) { + const { t } = useTranslation(); + + return ( +
+ + +
+ ); +} + +function PaymentMethodCard({ + method, + isSelected, + selectedSubOption, + onSelect, + onSelectSubOption, +}: { + method: GiftPaymentMethod; + isSelected: boolean; + selectedSubOption: string | null; + onSelect: () => void; + onSelectSubOption: (subOptionId: string) => void; +}) { + const hasSubOptions = method.sub_options && method.sub_options.length > 1; + + return ( +
+ + + {/* Sub-options */} + {isSelected && hasSubOptions && ( +
+
+ {method.sub_options!.map((opt) => ( + + ))} +
+
+ )} +
+ ); +} + +function RecipientSection({ value, onChange }: { value: string; onChange: (v: string) => void }) { + const { t } = useTranslation(); + + return ( +
+
+ + onChange(e.target.value)} + placeholder={t('gift.recipientPlaceholder', 'email@example.com or @telegram')} + className="w-full rounded-xl border border-dark-700/50 bg-dark-800/50 px-4 py-3 text-sm text-dark-50 placeholder-dark-500 outline-none transition-colors focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/25" + /> +

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

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