From d228d997d8360f8a15a23ec007a06048af7bd47d Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 6 Mar 2026 21:59:59 +0300 Subject: [PATCH] feat: guest purchase cabinet credentials UI - CabinetCredentialsState with copyable email/password fields - Auto-login via JWT token with checkAdminStatus - /auto-login route with ErrorBoundary and no-referrer meta - Narrowed contact_type to 'email' | 'telegram' | null - Disabled button styling for PendingActivation go-to-cabinet - i18n: added landing.copy key to all 4 locales --- src/App.tsx | 11 ++ src/api/auth.ts | 6 + src/api/landings.ts | 4 + src/locales/en.json | 13 +- src/locales/fa.json | 13 +- src/locales/ru.json | 13 +- src/locales/zh.json | 13 +- src/pages/AutoLogin.tsx | 82 ++++++++++ src/pages/PurchaseSuccess.tsx | 286 ++++++++++++++++++++++++++++++---- 9 files changed, 410 insertions(+), 31 deletions(-) create mode 100644 src/pages/AutoLogin.tsx diff --git a/src/App.tsx b/src/App.tsx index 7b70b76..f7ace2c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -40,6 +40,7 @@ const Connection = lazy(() => import('./pages/Connection')); const ConnectionQR = lazy(() => import('./pages/ConnectionQR')); const QuickPurchase = lazy(() => import('./pages/QuickPurchase')); const PurchaseSuccess = lazy(() => import('./pages/PurchaseSuccess')); +const AutoLogin = lazy(() => import('./pages/AutoLogin')); const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect')); const TopUpAmount = lazy(() => import('./pages/TopUpAmount')); const ConnectedAccounts = lazy(() => import('./pages/ConnectedAccounts')); @@ -219,6 +220,16 @@ function App() { } /> + + + + + + } + /> {/* Protected routes */} => { + const response = await apiClient.post('/cabinet/auth/login/auto', { token }); + return response.data; + }, + // Account merge (no JWT required) getMergePreview: async (mergeToken: string): Promise => { const response = await apiClient.get( diff --git a/src/api/landings.ts b/src/api/landings.ts index 947dcad..610b831 100644 --- a/src/api/landings.ts +++ b/src/api/landings.ts @@ -90,6 +90,10 @@ export interface PurchaseStatus { period_days: number | null; tariff_name: string | null; gift_message: string | null; + contact_type: 'email' | 'telegram' | null; + cabinet_email: string | null; + cabinet_password: string | null; + auto_login_token: string | null; } // ============================================================ diff --git a/src/locales/en.json b/src/locales/en.json index c186498..f6ca25d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3960,6 +3960,7 @@ "total": "Total", "pay": "Pay", "choosePeriod": "Choose period", + "copy": "Copy", "copied": "Copied!", "copyLink": "Copy link", "giftToggleLabel": "Purchase type", @@ -3970,6 +3971,16 @@ "activateNow": "Activate now", "activating": "Activating...", "activationFailed": "Activation failed", - "giftMessage": "Message" + "giftMessage": "Message", + "cabinetReady": "Your account is ready", + "cabinetEmail": "Email", + "cabinetPassword": "Password", + "goToCabinet": "Go to Cabinet", + "saveCredentials": "Save these login credentials", + "credentialsSentToEmail": "Login credentials sent to your email", + "giftSentSuccess": "Gift sent!", + "giftSentDesc": "Recipient will be notified by email", + "autoLoginFailed": "Auto-login failed", + "autoLoginProcessing": "Signing in..." } } diff --git a/src/locales/fa.json b/src/locales/fa.json index e4188f0..5b1bbb9 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -3511,6 +3511,7 @@ "total": "مجموع", "pay": "پرداخت", "choosePeriod": "انتخاب دوره", + "copy": "کپی", "copied": "کپی شد!", "copyLink": "کپی لینک", "giftToggleLabel": "\u0646\u0648\u0639 \u062e\u0631\u06cc\u062f", @@ -3521,6 +3522,16 @@ "activateNow": "فعال‌سازی", "activating": "در حال فعال‌سازی...", "activationFailed": "خطا در فعال‌سازی", - "giftMessage": "پیام" + "giftMessage": "پیام", + "cabinetReady": "حساب شما آماده است", + "cabinetEmail": "ایمیل", + "cabinetPassword": "رمز عبور", + "goToCabinet": "رفتن به پنل کاربری", + "saveCredentials": "این اطلاعات ورود را ذخیره کنید", + "credentialsSentToEmail": "اطلاعات ورود به ایمیل شما ارسال شد", + "giftSentSuccess": "هدیه ارسال شد!", + "giftSentDesc": "گیرنده از طریق ایمیل مطلع خواهد شد", + "autoLoginFailed": "ورود خودکار ناموفق بود", + "autoLoginProcessing": "در حال ورود..." } } diff --git a/src/locales/ru.json b/src/locales/ru.json index dcc866b..774e1a8 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -4523,6 +4523,7 @@ "total": "Итого", "pay": "Оплатить", "choosePeriod": "Выберите период", + "copy": "Скопировать", "copied": "Скопировано!", "copyLink": "Скопировать ссылку", "giftToggleLabel": "Тип покупки", @@ -4533,6 +4534,16 @@ "activateNow": "Активировать", "activating": "Активация...", "activationFailed": "Ошибка активации", - "giftMessage": "Сообщение" + "giftMessage": "Сообщение", + "cabinetReady": "Ваш аккаунт готов", + "cabinetEmail": "Email", + "cabinetPassword": "Пароль", + "goToCabinet": "Перейти в кабинет", + "saveCredentials": "Сохраните эти данные для входа", + "credentialsSentToEmail": "Данные для входа отправлены на email", + "giftSentSuccess": "Подарок отправлен!", + "giftSentDesc": "Получатель получит уведомление на email", + "autoLoginFailed": "Не удалось выполнить автоматический вход", + "autoLoginProcessing": "Выполняется вход..." } } diff --git a/src/locales/zh.json b/src/locales/zh.json index 19db57b..fd4cb1f 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -3510,6 +3510,7 @@ "total": "总计", "pay": "支付", "choosePeriod": "选择时长", + "copy": "复制", "copied": "已复制!", "copyLink": "复制链接", "giftToggleLabel": "\u8d2d\u4e70\u7c7b\u578b", @@ -3520,6 +3521,16 @@ "activateNow": "立即激活", "activating": "激活中...", "activationFailed": "激活失败", - "giftMessage": "留言" + "giftMessage": "留言", + "cabinetReady": "您的账户已准备就绪", + "cabinetEmail": "邮箱", + "cabinetPassword": "密码", + "goToCabinet": "前往个人中心", + "saveCredentials": "请保存这些登录信息", + "credentialsSentToEmail": "登录信息已发送到您的邮箱", + "giftSentSuccess": "礼物已发送!", + "giftSentDesc": "收件人将通过邮件收到通知", + "autoLoginFailed": "自动登录失败", + "autoLoginProcessing": "正在登录..." } } diff --git a/src/pages/AutoLogin.tsx b/src/pages/AutoLogin.tsx new file mode 100644 index 0000000..0587951 --- /dev/null +++ b/src/pages/AutoLogin.tsx @@ -0,0 +1,82 @@ +import { useEffect, useState, useRef } from 'react'; +import { useSearchParams, useNavigate } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { authApi } from '../api/auth'; +import { useAuthStore } from '../store/auth'; + +export default function AutoLogin() { + const { t } = useTranslation(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const { setTokens, setUser, checkAdminStatus } = useAuthStore(); + const [error, setError] = useState(false); + const attemptedRef = useRef(false); + + const token = searchParams.get('token'); + + useEffect(() => { + // Prevent referrer leaking the token + const meta = document.createElement('meta'); + meta.name = 'referrer'; + meta.content = 'no-referrer'; + document.head.appendChild(meta); + return () => { + document.head.removeChild(meta); + }; + }, []); + + useEffect(() => { + if (!token || attemptedRef.current) { + if (!token) setError(true); + return; + } + attemptedRef.current = true; + + authApi + .autoLogin(token) + .then(async (response) => { + setTokens(response.access_token, response.refresh_token); + setUser(response.user); + await checkAdminStatus(); + navigate('/', { replace: true }); + }) + .catch(() => { + setError(true); + }); + }, [token, navigate, setTokens, setUser, checkAdminStatus]); + + return ( +
+
+ {error ? ( +
+
+ + + +
+

{t('landing.autoLoginFailed')}

+ +
+ ) : ( +
+
+

{t('landing.autoLoginProcessing')}

+
+ )} +
+
+ ); +} diff --git a/src/pages/PurchaseSuccess.tsx b/src/pages/PurchaseSuccess.tsx index c904ab4..aacb47c 100644 --- a/src/pages/PurchaseSuccess.tsx +++ b/src/pages/PurchaseSuccess.tsx @@ -1,10 +1,12 @@ import { useState, useCallback, useRef, useEffect } from 'react'; -import { useParams } from 'react-router'; -import { useQuery } from '@tanstack/react-query'; +import { useParams, useNavigate } from 'react-router'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { motion } from 'framer-motion'; import { QRCodeSVG } from 'qrcode.react'; import { landingApi } from '../api/landings'; +import { authApi } from '../api/auth'; +import { useAuthStore } from '../store/auth'; import { copyToClipboard } from '../utils/clipboard'; import { cn } from '../lib/utils'; @@ -45,6 +47,168 @@ function PendingState() { ); } +function CopyableField({ label, value }: { label: string; value: string }) { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + const timeoutRef = useRef>(undefined); + + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + const handleCopy = useCallback(async () => { + try { + await copyToClipboard(value); + setCopied(true); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard write failed silently + } + }, [value]); + + return ( +
+
+

{label}

+

{value}

+
+ +
+ ); +} + +function CabinetCredentialsState({ + cabinetEmail, + cabinetPassword, + autoLoginToken, + tariffName, + periodDays, +}: { + cabinetEmail: string; + cabinetPassword: string | null; + autoLoginToken: string | null; + tariffName: string | null; + periodDays: number | null; +}) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { setTokens, setUser, checkAdminStatus } = useAuthStore(); + const [isLoggingIn, setIsLoggingIn] = useState(false); + const [loginError, setLoginError] = useState(false); + + const handleGoToCabinet = useCallback(async () => { + if (!autoLoginToken) { + navigate('/login'); + return; + } + setIsLoggingIn(true); + setLoginError(false); + try { + const response = await authApi.autoLogin(autoLoginToken); + setTokens(response.access_token, response.refresh_token); + setUser(response.user); + await checkAdminStatus(); + navigate('/'); + } catch { + setLoginError(true); + setIsLoggingIn(false); + } + }, [autoLoginToken, navigate, setTokens, setUser, checkAdminStatus]); + + return ( + + {/* Animated checkmark */} + + + + + + + {/* Title */} +
+

{t('landing.cabinetReady')}

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

+ {tariffName} — {periodDays} {t('landing.daysAccess')} +

+ )} +
+ + {/* Credentials */} +
+ + {cabinetPassword && ( + + )} + {cabinetPassword &&

{t('landing.saveCredentials')}

} + {!cabinetPassword && ( +

{t('landing.credentialsSentToEmail')}

+ )} +
+ + {/* Go to Cabinet button */} + + {loginError &&

{t('landing.autoLoginFailed')}

} +
+ ); +} + function SuccessState({ subscriptionUrl, cryptoLink, @@ -127,8 +291,10 @@ function SuccessState({ {/* Title */}
-

{t('landing.purchaseSuccess')}

- {tariffName && periodDays && ( +

+ {isGift ? t('landing.giftSentSuccess') : t('landing.purchaseSuccess')} +

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

{tariffName} — {periodDays} {t('landing.daysAccess')}

@@ -216,6 +382,7 @@ function PendingActivationState({ isGift, isActivating, onActivate, + autoLoginToken, }: { tariffName: string | null; periodDays: number | null; @@ -223,8 +390,30 @@ function PendingActivationState({ isGift: boolean; isActivating: boolean; onActivate: () => void; + autoLoginToken: string | null; }) { const { t } = useTranslation(); + const navigate = useNavigate(); + const { setTokens, setUser, checkAdminStatus } = useAuthStore(); + const [isLoggingIn, setIsLoggingIn] = useState(false); + + const handleGoToCabinet = useCallback(async () => { + if (!autoLoginToken) { + navigate('/login'); + return; + } + setIsLoggingIn(true); + try { + const response = await authApi.autoLogin(autoLoginToken); + setTokens(response.access_token, response.refresh_token); + setUser(response.user); + await checkAdminStatus(); + navigate('/'); + } catch { + setIsLoggingIn(false); + navigate('/login'); + } + }, [autoLoginToken, navigate, setTokens, setUser, checkAdminStatus]); return (

{t('landing.pendingActivation')}

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

{tariffName} — {periodDays} {t('landing.daysAccess')}

@@ -264,26 +453,51 @@ function PendingActivationState({ )}
- + + {autoLoginToken && ( + )} - > - {isActivating ? ( - <> - - {t('landing.activating')} - - ) : ( - t('landing.activateNow') - )} - +
); } @@ -386,6 +600,8 @@ export default function PurchaseSuccess() { }; }, []); + const queryClient = useQueryClient(); + const { data: purchaseStatus, isError, @@ -420,25 +636,40 @@ export default function PurchaseSuccess() { setIsActivating(true); setActivationError(false); try { - await landingApi.activatePurchase(token); - await refetch(); + const result = await landingApi.activatePurchase(token); + queryClient.setQueryData(['purchase-status', token], result); } catch { setActivationError(true); } finally { activatingRef.current = false; setIsActivating(false); } - }, [token, refetch]); + }, [token, queryClient]); const isSuccess = purchaseStatus?.status === 'delivered'; const isPendingActivation = purchaseStatus?.status === 'pending_activation'; const isFailed = purchaseStatus?.status === 'failed' || purchaseStatus?.status === 'expired'; + // Email self-purchase delivered → show cabinet credentials + const isEmailSelfPurchase = + isSuccess && + purchaseStatus.contact_type === 'email' && + !purchaseStatus.is_gift && + purchaseStatus.cabinet_email; + return (
{isError ? ( + ) : isEmailSelfPurchase ? ( + ) : isSuccess ? ( {activationError && (

{t('landing.activationFailed')}