From db31d395cd70055da8b0cbf697fc70173b45401a Mon Sep 17 00:00:00 2001 From: c0mrade Date: Wed, 3 Jun 2026 13:46:00 +0300 Subject: [PATCH] =?UTF-8?q?feat(gift):=20transferable=20gift=20claim=20?= =?UTF-8?q?=E2=80=94=20buyer=20share=20link=20+=20recipient=20claim=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairs with the bot's unified claimable-gift model. A landing gift now lands on the buyer's success page as PAID/claimable (deferred) instead of being delivered to a phantom recipient, so the buyer gets a transferable link to forward and the recipient claims it themselves — guaranteeing delivery regardless of channel or whether they pre-exist. - api/landings.ts: PurchaseStatus gains is_claimable/claim_url/bot_claim_link; add GiftClaimResult + getGiftClaim()/claimGift(). - PurchaseSuccess.tsx: GiftLinkShareState — buyer sees the claim link + Telegram link + copy-message when a gift is PAID/claimable; stop polling for that state (it stays PAID until claimed) instead of spinning forever. - GiftClaim.tsx (new) + /buy/gift/:token route: public recipient claim page with a Telegram arm (GIFT_ deep link) and a web/email arm (binds + one-click login), polling while the payment settles, idempotent "already claimed" + 404 states. - i18n: landing.giftClaim.* / landing.giftLink.* in ru/en/zh/fa. --- src/App.tsx | 9 + src/api/landings.ts | 27 +++ src/locales/en.json | 28 ++++ src/locales/fa.json | 28 ++++ src/locales/ru.json | 28 ++++ src/locales/zh.json | 28 ++++ src/pages/GiftClaim.tsx | 302 ++++++++++++++++++++++++++++++++++ src/pages/PurchaseSuccess.tsx | 130 ++++++++++++++- 8 files changed, 579 insertions(+), 1 deletion(-) create mode 100644 src/pages/GiftClaim.tsx diff --git a/src/App.tsx b/src/App.tsx index 589781d..d9cbe47 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -65,6 +65,7 @@ const Connection = lazyWithRetry(() => import('./pages/Connection')); const ConnectionQR = lazyWithRetry(() => import('./pages/ConnectionQR')); const QuickPurchase = lazyWithRetry(() => import('./pages/QuickPurchase')); const PurchaseSuccess = lazyWithRetry(() => import('./pages/PurchaseSuccess')); +const GiftClaim = lazyWithRetry(() => import('./pages/GiftClaim')); const RenewSubscription = lazyWithRetry(() => import('./pages/RenewSubscription')); const AutoLogin = lazyWithRetry(() => import('./pages/AutoLogin')); const TopUpMethodSelect = lazyWithRetry(() => import('./pages/TopUpMethodSelect')); @@ -276,6 +277,14 @@ function App() { } /> + + + + } + /> => { + const response = await apiClient.get(`/cabinet/landing/gift/${token}`); + return response.data; + }, + + // Web (email) arm of the gift claim — binds the gift to the given email account. + claimGift: async (token: string, email: string): Promise => { + const response = await apiClient.post(`/cabinet/landing/gift/${token}/claim`, { email }); + return response.data; + }, }; export interface LandingDailyStat { diff --git a/src/locales/en.json b/src/locales/en.json index 81f4f61..0d3b4c8 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -4834,6 +4834,34 @@ "merging": "Merging..." }, "landing": { + "giftClaim": { + "title": "You have a gift!", + "error": "Could not activate the gift.", + "notFoundTitle": "Gift not found", + "notFoundDesc": "This gift link is invalid or no longer available.", + "alreadyTitle": "Gift already activated", + "alreadyDesc": "This gift has already been claimed.", + "successTitle": "Gift activated!", + "connectDesc": "Use this link to connect:", + "copyLink": "Copy link", + "pendingTitle": "Almost ready…", + "pendingDesc": "The payment is still being confirmed. This page will update automatically.", + "replaceWarning": "You already have a subscription — activating this gift will replace it.", + "activateTelegram": "Activate in Telegram", + "activateWeb": "Activate by email", + "emailLabel": "Your email", + "claimNow": "Get my gift" + }, + "giftLink": { + "shareText": "I have a gift for you! Activate it here:", + "viaTelegram": "Telegram:", + "title": "Gift is ready!", + "subtitle": "Send this link to whoever you want to receive the gift — they activate it themselves.", + "linkLabel": "Gift link", + "telegramLabel": "Telegram link", + "alsoSent": "We also emailed it to {{contact}}.", + "copyMessage": "Copy message" + }, "notFound": "Page not found", "forMe": "For me", "asGift": "As a gift", diff --git a/src/locales/fa.json b/src/locales/fa.json index c471622..8596469 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -4494,6 +4494,34 @@ "merging": "در حال ادغام..." }, "landing": { + "giftClaim": { + "title": "شما یک هدیه دارید!", + "error": "فعال‌سازی هدیه ناموفق بود.", + "notFoundTitle": "هدیه یافت نشد", + "notFoundDesc": "این لینک هدیه نامعتبر یا دیگر در دسترس نیست.", + "alreadyTitle": "هدیه قبلاً فعال شده است", + "alreadyDesc": "این هدیه قبلاً دریافت شده است.", + "successTitle": "هدیه فعال شد!", + "connectDesc": "از این لینک برای اتصال استفاده کنید:", + "copyLink": "کپی لینک", + "pendingTitle": "تقریباً آماده است…", + "pendingDesc": "پرداخت در حال تأیید است. این صفحه به‌طور خودکار به‌روزرسانی می‌شود.", + "replaceWarning": "شما در حال حاضر اشتراک دارید — فعال‌سازی این هدیه جایگزین آن خواهد شد.", + "activateTelegram": "فعال‌سازی در تلگرام", + "activateWeb": "فعال‌سازی با ایمیل", + "emailLabel": "ایمیل شما", + "claimNow": "دریافت هدیه" + }, + "giftLink": { + "shareText": "یک هدیه برای شما دارم! اینجا فعال کنید:", + "viaTelegram": "تلگرام:", + "title": "هدیه آماده است!", + "subtitle": "این لینک را برای کسی که می‌خواهید هدیه را دریافت کند بفرستید — خودش آن را فعال می‌کند.", + "linkLabel": "لینک هدیه", + "telegramLabel": "لینک تلگرام", + "alsoSent": "آن را به {{contact}} نیز ایمیل کردیم.", + "copyMessage": "کپی پیام" + }, "notFound": "صفحه یافت نشد", "forMe": "برای خودم", "asGift": "به عنوان هدیه", diff --git a/src/locales/ru.json b/src/locales/ru.json index 3fefdf8..217c878 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -5389,6 +5389,34 @@ "merging": "Объединение..." }, "landing": { + "giftClaim": { + "title": "Вам подарок!", + "error": "Не удалось активировать подарок.", + "notFoundTitle": "Подарок не найден", + "notFoundDesc": "Ссылка на подарок недействительна или больше недоступна.", + "alreadyTitle": "Подарок уже активирован", + "alreadyDesc": "Этот подарок уже забрали.", + "successTitle": "Подарок активирован!", + "connectDesc": "Используйте эту ссылку для подключения:", + "copyLink": "Скопировать ссылку", + "pendingTitle": "Почти готово…", + "pendingDesc": "Оплата ещё подтверждается. Страница обновится автоматически.", + "replaceWarning": "У вас уже есть подписка — активация подарка заменит её.", + "activateTelegram": "Активировать в Telegram", + "activateWeb": "Активировать по почте", + "emailLabel": "Ваш email", + "claimNow": "Получить подарок" + }, + "giftLink": { + "shareText": "Дарю вам подписку! Активируйте здесь:", + "viaTelegram": "Telegram:", + "title": "Подарок готов!", + "subtitle": "Отправьте эту ссылку тому, кому предназначен подарок — он активирует его сам.", + "linkLabel": "Ссылка на подарок", + "telegramLabel": "Ссылка для Telegram", + "alsoSent": "Мы также отправили её на {{contact}}.", + "copyMessage": "Скопировать сообщение" + }, "notFound": "Страница не найдена", "forMe": "Для себя", "asGift": "В подарок", diff --git a/src/locales/zh.json b/src/locales/zh.json index 8e59695..a06eeef 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -4493,6 +4493,34 @@ "merging": "合并中..." }, "landing": { + "giftClaim": { + "title": "您收到一份礼物!", + "error": "无法激活礼物。", + "notFoundTitle": "未找到礼物", + "notFoundDesc": "此礼物链接无效或已不可用。", + "alreadyTitle": "礼物已激活", + "alreadyDesc": "此礼物已被领取。", + "successTitle": "礼物已激活!", + "connectDesc": "使用此链接连接:", + "copyLink": "复制链接", + "pendingTitle": "即将完成…", + "pendingDesc": "付款仍在确认中。本页面将自动更新。", + "replaceWarning": "您已有订阅——激活此礼物将替换它。", + "activateTelegram": "在 Telegram 中激活", + "activateWeb": "通过邮箱激活", + "emailLabel": "您的邮箱", + "claimNow": "领取礼物" + }, + "giftLink": { + "shareText": "我送您一份订阅!在此激活:", + "viaTelegram": "Telegram:", + "title": "礼物已就绪!", + "subtitle": "将此链接发送给您想赠送的人——他们自行激活。", + "linkLabel": "礼物链接", + "telegramLabel": "Telegram 链接", + "alsoSent": "我们也已发送到 {{contact}}。", + "copyMessage": "复制消息" + }, "notFound": "页面未找到", "forMe": "为自己", "asGift": "作为礼物", diff --git a/src/pages/GiftClaim.tsx b/src/pages/GiftClaim.tsx new file mode 100644 index 0000000..11c4dbd --- /dev/null +++ b/src/pages/GiftClaim.tsx @@ -0,0 +1,302 @@ +import { useMemo, useRef, useState } from 'react'; +import { useParams, useNavigate } from 'react-router'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { motion } from 'framer-motion'; +import { landingApi, type GiftClaimResult } from '../api/landings'; +import { getApiErrorMessage } from '../utils/api-error'; +import { copyToClipboard } from '@/utils/clipboard'; +import { Spinner } from '@/components/ui/Spinner'; +import { AnimatedCheckmark } from '@/components/ui/AnimatedCheckmark'; +import { cn } from '@/lib/utils'; +import { CheckCircleIcon, CheckIcon, CopyIcon } from '@/components/icons'; + +const MAX_POLL_MS = 10 * 60 * 1000; // poll an unsettled payment for up to 10 min + +function isValidEmail(value: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value.trim()); +} + +function Shell({ children }: { children: React.ReactNode }) { + return ( +
+
+ {children} +
+
+ ); +} + +export default function GiftClaim() { + const { token } = useParams<{ token: string }>(); + const { t } = useTranslation(); + const navigate = useNavigate(); + const startedAt = useRef(Date.now()); + + const [email, setEmail] = useState(''); + const [showEmail, setShowEmail] = useState(false); + const [claimError, setClaimError] = useState(null); + const [result, setResult] = useState(null); + const [copied, setCopied] = useState(false); + + const { + data: gift, + isLoading, + error, + } = useQuery({ + queryKey: ['gift-claim', token], + queryFn: () => landingApi.getGiftClaim(token!), + enabled: !!token, + retry: 1, + // Poll only while the payment is still settling (not yet claimable / terminal). + refetchInterval: (query) => { + const data = query.state.data; + if (!data) return 3000; + if (Date.now() - startedAt.current > MAX_POLL_MS) return false; + const settled = data.is_claimable || data.status === 'delivered' || data.status === 'failed'; + return settled ? false : 3000; + }, + }); + + const claimMutation = useMutation({ + mutationFn: () => landingApi.claimGift(token!, email.trim()), + onSuccess: (res) => { + // One-click cabinet login for a fresh email account; otherwise show the link. + if (res.auto_login_token) { + navigate(`/auto-login?token=${encodeURIComponent(res.auto_login_token)}`); + return; + } + setResult(res); + }, + onError: (err) => { + setClaimError( + getApiErrorMessage(err, t('landing.giftClaim.error', 'Could not activate the gift.')), + ); + }, + }); + + const willReplace = gift?.status === 'pending_activation'; + + const handleCopyLink = async () => { + const url = result?.subscription_url; + if (!url) return; + try { + await copyToClipboard(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + /* ignore */ + } + }; + + const periodLabel = useMemo(() => { + const days = gift?.period_days; + if (!days) return ''; + return `${days} ${t('gift.days', 'days')}`; + }, [gift?.period_days, t]); + + if (isLoading) { + return ( + +
+ +

{t('common.loading', 'Loading...')}

+
+
+ ); + } + + // 404 / unknown gift + if (error || !gift || !gift.is_gift) { + return ( + +
+

+ {t('landing.giftClaim.notFoundTitle', 'Gift not found')} +

+

+ {t( + 'landing.giftClaim.notFoundDesc', + 'This gift link is invalid or no longer available.', + )} +

+
+
+ ); + } + + // Already activated + if (gift.status === 'delivered' && !result) { + return ( + +
+ +

+ {t('landing.giftClaim.alreadyTitle', 'Gift already activated')} +

+

+ {t('landing.giftClaim.alreadyDesc', 'This gift has already been claimed.')} +

+
+
+ ); + } + + // Successful web claim → show connection link + if (result) { + return ( + + + +

+ {t('landing.giftClaim.successTitle', 'Gift activated!')} +

+ {result.subscription_url && ( + <> +

+ {t('landing.giftClaim.connectDesc', 'Use this link to connect:')} +

+

+ {result.subscription_url} +

+ + + )} +
+
+ ); + } + + // Payment still settling + if (!gift.is_claimable) { + return ( + +
+ +

+ {t('landing.giftClaim.pendingTitle', 'Almost ready...')} +

+

+ {t( + 'landing.giftClaim.pendingDesc', + 'The payment is still being confirmed. This page will update automatically.', + )} +

+
+
+ ); + } + + // Claimable — offer Telegram + web arms + return ( + + +
🎁
+
+

+ {t('landing.giftClaim.title', 'You have a gift!')} +

+ {gift.tariff_name && ( +

+ {gift.tariff_name} + {periodLabel ? ` — ${periodLabel}` : ''} +

+ )} +
+ + {gift.gift_message && ( +
+

“{gift.gift_message}”

+
+ )} + + {willReplace && ( +

+ {t( + 'landing.giftClaim.replaceWarning', + 'You already have a subscription — activating this gift will replace it.', + )} +

+ )} + + {/* Telegram arm */} + {gift.bot_claim_link && ( + + {t('landing.giftClaim.activateTelegram', 'Activate in Telegram')} + + )} + + {/* Web/email arm */} + {!showEmail ? ( + + ) : ( +
+ + { + setEmail(e.target.value); + setClaimError(null); + }} + placeholder="email@example.com" + 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 focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/25" + /> + {claimError &&

{claimError}

} + +
+ )} +
+
+ ); +} diff --git a/src/pages/PurchaseSuccess.tsx b/src/pages/PurchaseSuccess.tsx index 8afddd9..0016cc7 100644 --- a/src/pages/PurchaseSuccess.tsx +++ b/src/pages/PurchaseSuccess.tsx @@ -553,6 +553,113 @@ function PollTimedOutState({ onRetry }: { onRetry: () => void }) { ); } +function GiftLinkShareState({ + claimUrl, + botClaimLink, + tariffName, + periodDays, + recipientContactValue, + contactType, +}: { + claimUrl: string | null; + botClaimLink: string | null; + tariffName: string | null; + periodDays: number | null; + recipientContactValue: string | null; + contactType: 'email' | 'telegram' | null; +}) { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + + const message = [ + t('landing.giftLink.shareText', 'I have a gift for you! Activate it here:'), + '', + claimUrl, + botClaimLink ? `${t('landing.giftLink.viaTelegram', 'Telegram:')} ${botClaimLink}` : null, + ] + .filter(Boolean) + .join('\n'); + + const handleCopy = async () => { + try { + await copyToClipboard(message); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + /* ignore */ + } + }; + + return ( + + +
+

+ {t('landing.giftLink.title', 'Gift is ready!')} +

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

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

+ )} +
+ +

+ {t( + 'landing.giftLink.subtitle', + 'Send this link to whoever you want to receive the gift — they activate it themselves.', + )} +

+ + {claimUrl && ( + + )} + {botClaimLink && ( + + )} + + {recipientContactValue && contactType === 'email' && ( +

+ {t('landing.giftLink.alsoSent', { + contact: recipientContactValue, + defaultValue: 'We also emailed it to {{contact}}.', + })} +

+ )} + + +
+ ); +} + export default function PurchaseSuccess() { const { t } = useTranslation(); const { token } = useParams<{ token: string }>(); @@ -586,7 +693,12 @@ export default function PurchaseSuccess() { queryFn: () => landingApi.getPurchaseStatus(token!), enabled: !!token && !pollTimedOut, refetchInterval: (query) => { - const currentStatus = query.state.data?.status; + const data = query.state.data; + const currentStatus = data?.status; + // A gift that reached PAID is claimable and terminal for the BUYER (it + // stays PAID until the recipient claims) — stop polling and show the + // share link instead of spinning forever. + if (currentStatus === 'paid' && data?.is_gift && data?.is_claimable) return false; if (currentStatus === 'pending' || currentStatus === 'paid') { if (Date.now() - pollStart.current > MAX_POLL_MS) { setPollTimedOut(true); @@ -652,6 +764,13 @@ export default function PurchaseSuccess() { const isPendingActivation = purchaseStatus?.status === 'pending_activation'; const isFailed = purchaseStatus?.status === 'failed' || purchaseStatus?.status === 'expired'; + // Deferred gift the buyer just paid for → show the transferable claim link to + // forward (it stays PAID until the recipient claims it). + const isBuyerGiftLink = + purchaseStatus?.status === 'paid' && + !!purchaseStatus?.is_gift && + !!purchaseStatus?.is_claimable; + // Gift pending activation → buyer sees "gift sent" message, not the activate button. // Recipient arrives via email link with ?activate=1 and sees the activate button instead. const isGiftPendingActivation = isPendingActivation && purchaseStatus?.is_gift && !isActivateHint; @@ -672,6 +791,15 @@ export default function PurchaseSuccess() { > {isError ? ( + ) : isBuyerGiftLink ? ( + ) : isEmailSelfPurchase ? (