mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
feat(gift): transferable gift claim — buyer share link + recipient claim page
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.
This commit is contained in:
@@ -65,6 +65,7 @@ const Connection = lazyWithRetry(() => import('./pages/Connection'));
|
|||||||
const ConnectionQR = lazyWithRetry(() => import('./pages/ConnectionQR'));
|
const ConnectionQR = lazyWithRetry(() => import('./pages/ConnectionQR'));
|
||||||
const QuickPurchase = lazyWithRetry(() => import('./pages/QuickPurchase'));
|
const QuickPurchase = lazyWithRetry(() => import('./pages/QuickPurchase'));
|
||||||
const PurchaseSuccess = lazyWithRetry(() => import('./pages/PurchaseSuccess'));
|
const PurchaseSuccess = lazyWithRetry(() => import('./pages/PurchaseSuccess'));
|
||||||
|
const GiftClaim = lazyWithRetry(() => import('./pages/GiftClaim'));
|
||||||
const RenewSubscription = lazyWithRetry(() => import('./pages/RenewSubscription'));
|
const RenewSubscription = lazyWithRetry(() => import('./pages/RenewSubscription'));
|
||||||
const AutoLogin = lazyWithRetry(() => import('./pages/AutoLogin'));
|
const AutoLogin = lazyWithRetry(() => import('./pages/AutoLogin'));
|
||||||
const TopUpMethodSelect = lazyWithRetry(() => import('./pages/TopUpMethodSelect'));
|
const TopUpMethodSelect = lazyWithRetry(() => import('./pages/TopUpMethodSelect'));
|
||||||
@@ -276,6 +277,14 @@ function App() {
|
|||||||
</LazyPage>
|
</LazyPage>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/buy/gift/:token"
|
||||||
|
element={
|
||||||
|
<LazyPage>
|
||||||
|
<GiftClaim />
|
||||||
|
</LazyPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/buy/:slug"
|
path="/buy/:slug"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -137,6 +137,21 @@ export interface PurchaseStatus {
|
|||||||
auto_login_token: string | null;
|
auto_login_token: string | null;
|
||||||
recipient_in_bot: boolean | null;
|
recipient_in_bot: boolean | null;
|
||||||
bot_link: string | null;
|
bot_link: string | null;
|
||||||
|
// Transferable gift claim link — the buyer forwards this; whoever activates it
|
||||||
|
// gets the gift. Derived from token + status (purchase.user is null until claim).
|
||||||
|
is_claimable: boolean;
|
||||||
|
claim_url: string | null;
|
||||||
|
bot_claim_link: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result returned to the recipient after a successful web (email) gift claim. */
|
||||||
|
export interface GiftClaimResult {
|
||||||
|
status: string;
|
||||||
|
tariff_name: string | null;
|
||||||
|
period_days: number | null;
|
||||||
|
subscription_url: string | null;
|
||||||
|
subscription_crypto_link: string | null;
|
||||||
|
auto_login_token: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Locale dict for multi-language text fields (admin API) */
|
/** Locale dict for multi-language text fields (admin API) */
|
||||||
@@ -282,6 +297,18 @@ export const landingApi = {
|
|||||||
const response = await apiClient.post(`/cabinet/landing/activate/${token}`);
|
const response = await apiClient.post(`/cabinet/landing/activate/${token}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Public gift claim page data (no auth — the token is the bearer secret).
|
||||||
|
getGiftClaim: async (token: string): Promise<PurchaseStatus> => {
|
||||||
|
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<GiftClaimResult> => {
|
||||||
|
const response = await apiClient.post(`/cabinet/landing/gift/${token}/claim`, { email });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface LandingDailyStat {
|
export interface LandingDailyStat {
|
||||||
|
|||||||
@@ -4834,6 +4834,34 @@
|
|||||||
"merging": "Merging..."
|
"merging": "Merging..."
|
||||||
},
|
},
|
||||||
"landing": {
|
"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",
|
"notFound": "Page not found",
|
||||||
"forMe": "For me",
|
"forMe": "For me",
|
||||||
"asGift": "As a gift",
|
"asGift": "As a gift",
|
||||||
|
|||||||
@@ -4494,6 +4494,34 @@
|
|||||||
"merging": "در حال ادغام..."
|
"merging": "در حال ادغام..."
|
||||||
},
|
},
|
||||||
"landing": {
|
"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": "صفحه یافت نشد",
|
"notFound": "صفحه یافت نشد",
|
||||||
"forMe": "برای خودم",
|
"forMe": "برای خودم",
|
||||||
"asGift": "به عنوان هدیه",
|
"asGift": "به عنوان هدیه",
|
||||||
|
|||||||
@@ -5389,6 +5389,34 @@
|
|||||||
"merging": "Объединение..."
|
"merging": "Объединение..."
|
||||||
},
|
},
|
||||||
"landing": {
|
"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": "Страница не найдена",
|
"notFound": "Страница не найдена",
|
||||||
"forMe": "Для себя",
|
"forMe": "Для себя",
|
||||||
"asGift": "В подарок",
|
"asGift": "В подарок",
|
||||||
|
|||||||
@@ -4493,6 +4493,34 @@
|
|||||||
"merging": "合并中..."
|
"merging": "合并中..."
|
||||||
},
|
},
|
||||||
"landing": {
|
"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": "页面未找到",
|
"notFound": "页面未找到",
|
||||||
"forMe": "为自己",
|
"forMe": "为自己",
|
||||||
"asGift": "作为礼物",
|
"asGift": "作为礼物",
|
||||||
|
|||||||
302
src/pages/GiftClaim.tsx
Normal file
302
src/pages/GiftClaim.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex min-h-dvh items-center justify-center bg-dark-950 px-4 py-10">
|
||||||
|
<div className="w-full max-w-md rounded-2xl border border-dark-800/50 bg-dark-900/50 p-6 sm:p-8">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string | null>(null);
|
||||||
|
const [result, setResult] = useState<GiftClaimResult | null>(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 (
|
||||||
|
<Shell>
|
||||||
|
<div className="flex flex-col items-center gap-4 py-6 text-center">
|
||||||
|
<Spinner className="h-12 w-12 border-[3px]" />
|
||||||
|
<p className="text-sm text-dark-400">{t('common.loading', 'Loading...')}</p>
|
||||||
|
</div>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 404 / unknown gift
|
||||||
|
if (error || !gift || !gift.is_gift) {
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||||
|
<h1 className="text-lg font-semibold text-dark-50">
|
||||||
|
{t('landing.giftClaim.notFoundTitle', 'Gift not found')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-dark-400">
|
||||||
|
{t(
|
||||||
|
'landing.giftClaim.notFoundDesc',
|
||||||
|
'This gift link is invalid or no longer available.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already activated
|
||||||
|
if (gift.status === 'delivered' && !result) {
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<div className="flex flex-col items-center gap-4 py-4 text-center">
|
||||||
|
<CheckCircleIcon className="h-14 w-14 text-success-400" />
|
||||||
|
<h1 className="text-xl font-bold text-dark-50">
|
||||||
|
{t('landing.giftClaim.alreadyTitle', 'Gift already activated')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-dark-400">
|
||||||
|
{t('landing.giftClaim.alreadyDesc', 'This gift has already been claimed.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Successful web claim → show connection link
|
||||||
|
if (result) {
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="flex flex-col items-center gap-5 text-center"
|
||||||
|
>
|
||||||
|
<AnimatedCheckmark />
|
||||||
|
<h1 className="text-xl font-bold text-dark-50">
|
||||||
|
{t('landing.giftClaim.successTitle', 'Gift activated!')}
|
||||||
|
</h1>
|
||||||
|
{result.subscription_url && (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-dark-300">
|
||||||
|
{t('landing.giftClaim.connectDesc', 'Use this link to connect:')}
|
||||||
|
</p>
|
||||||
|
<p className="w-full select-all truncate rounded-lg bg-dark-900/60 px-3 py-2 text-sm text-accent-400">
|
||||||
|
{result.subscription_url}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyLink}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-bold transition-all active:scale-[0.98]',
|
||||||
|
copied
|
||||||
|
? 'bg-success-500/20 text-success-400'
|
||||||
|
: 'bg-accent-500 text-white hover:bg-accent-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{copied ? <CheckIcon className="h-4 w-4" /> : <CopyIcon className="h-4 w-4" />}
|
||||||
|
{copied
|
||||||
|
? t('common.copied', 'Copied!')
|
||||||
|
: t('landing.giftClaim.copyLink', 'Copy link')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payment still settling
|
||||||
|
if (!gift.is_claimable) {
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<div className="flex flex-col items-center gap-4 py-6 text-center">
|
||||||
|
<Spinner className="h-12 w-12 border-[3px]" />
|
||||||
|
<h1 className="text-lg font-semibold text-dark-50">
|
||||||
|
{t('landing.giftClaim.pendingTitle', 'Almost ready...')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-dark-400">
|
||||||
|
{t(
|
||||||
|
'landing.giftClaim.pendingDesc',
|
||||||
|
'The payment is still being confirmed. This page will update automatically.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claimable — offer Telegram + web arms
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="flex flex-col items-center gap-5 text-center"
|
||||||
|
>
|
||||||
|
<div className="text-4xl">🎁</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-dark-50">
|
||||||
|
{t('landing.giftClaim.title', 'You have a gift!')}
|
||||||
|
</h1>
|
||||||
|
{gift.tariff_name && (
|
||||||
|
<p className="mt-1 text-sm text-dark-300">
|
||||||
|
{gift.tariff_name}
|
||||||
|
{periodLabel ? ` — ${periodLabel}` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{gift.gift_message && (
|
||||||
|
<div className="w-full rounded-xl border border-dark-700/30 bg-dark-800/40 p-4 text-left">
|
||||||
|
<p className="text-sm italic text-dark-200">“{gift.gift_message}”</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{willReplace && (
|
||||||
|
<p className="w-full rounded-lg border border-warning-500/20 bg-warning-500/5 p-3 text-xs text-warning-400">
|
||||||
|
{t(
|
||||||
|
'landing.giftClaim.replaceWarning',
|
||||||
|
'You already have a subscription — activating this gift will replace it.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Telegram arm */}
|
||||||
|
{gift.bot_claim_link && (
|
||||||
|
<a
|
||||||
|
href={gift.bot_claim_link}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-xl bg-accent-500 px-6 py-3.5 text-sm font-bold text-white shadow-lg shadow-accent-500/25 transition-all hover:bg-accent-400 active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
{t('landing.giftClaim.activateTelegram', 'Activate in Telegram')}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Web/email arm */}
|
||||||
|
{!showEmail ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowEmail(true)}
|
||||||
|
className="w-full rounded-xl border border-dark-700/50 px-6 py-3 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-800/50"
|
||||||
|
>
|
||||||
|
{t('landing.giftClaim.activateWeb', 'Activate by email')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="w-full space-y-3 text-left">
|
||||||
|
<label htmlFor="claim-email" className="block text-sm font-medium text-dark-200">
|
||||||
|
{t('landing.giftClaim.emailLabel', 'Your email')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="claim-email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => {
|
||||||
|
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 && <p className="text-sm text-error-400">{claimError}</p>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!isValidEmail(email) || claimMutation.isPending}
|
||||||
|
onClick={() => claimMutation.mutate()}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center justify-center gap-2 rounded-xl px-6 py-3.5 text-sm font-bold transition-all',
|
||||||
|
isValidEmail(email) && !claimMutation.isPending
|
||||||
|
? 'bg-accent-500 text-white hover:bg-accent-400 active:scale-[0.98]'
|
||||||
|
: 'cursor-not-allowed bg-dark-800 text-dark-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{claimMutation.isPending ? (
|
||||||
|
<Spinner className="h-5 w-5 border-2" />
|
||||||
|
) : (
|
||||||
|
t('landing.giftClaim.claimNow', 'Get my gift')
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="flex flex-col items-center gap-5 text-center"
|
||||||
|
>
|
||||||
|
<AnimatedCheckmark />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-dark-50">
|
||||||
|
{t('landing.giftLink.title', 'Gift is ready!')}
|
||||||
|
</h1>
|
||||||
|
{tariffName && periodDays !== null && (
|
||||||
|
<p className="mt-1 text-sm text-dark-300">
|
||||||
|
{tariffName} — {periodDays} {t('landing.days', 'days')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-dark-300">
|
||||||
|
{t(
|
||||||
|
'landing.giftLink.subtitle',
|
||||||
|
'Send this link to whoever you want to receive the gift — they activate it themselves.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{claimUrl && (
|
||||||
|
<CopyableField label={t('landing.giftLink.linkLabel', 'Gift link')} value={claimUrl} />
|
||||||
|
)}
|
||||||
|
{botClaimLink && (
|
||||||
|
<CopyableField
|
||||||
|
label={t('landing.giftLink.telegramLabel', 'Telegram link')}
|
||||||
|
value={botClaimLink}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recipientContactValue && contactType === 'email' && (
|
||||||
|
<p className="text-xs text-dark-500">
|
||||||
|
{t('landing.giftLink.alsoSent', {
|
||||||
|
contact: recipientContactValue,
|
||||||
|
defaultValue: 'We also emailed it to {{contact}}.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center justify-center gap-2 rounded-xl px-4 py-3.5 text-sm font-bold transition-all duration-200 active:scale-[0.98]',
|
||||||
|
copied
|
||||||
|
? 'bg-success-500/20 text-success-400'
|
||||||
|
: 'bg-accent-500 text-white shadow-lg shadow-accent-500/25 hover:bg-accent-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<CheckIcon className="h-4 w-4" />
|
||||||
|
{t('landing.copied', 'Copied!')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ClipboardIcon className="h-4 w-4" />
|
||||||
|
{t('landing.giftLink.copyMessage', 'Copy message')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function PurchaseSuccess() {
|
export default function PurchaseSuccess() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { token } = useParams<{ token: string }>();
|
const { token } = useParams<{ token: string }>();
|
||||||
@@ -586,7 +693,12 @@ export default function PurchaseSuccess() {
|
|||||||
queryFn: () => landingApi.getPurchaseStatus(token!),
|
queryFn: () => landingApi.getPurchaseStatus(token!),
|
||||||
enabled: !!token && !pollTimedOut,
|
enabled: !!token && !pollTimedOut,
|
||||||
refetchInterval: (query) => {
|
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 (currentStatus === 'pending' || currentStatus === 'paid') {
|
||||||
if (Date.now() - pollStart.current > MAX_POLL_MS) {
|
if (Date.now() - pollStart.current > MAX_POLL_MS) {
|
||||||
setPollTimedOut(true);
|
setPollTimedOut(true);
|
||||||
@@ -652,6 +764,13 @@ export default function PurchaseSuccess() {
|
|||||||
const isPendingActivation = purchaseStatus?.status === 'pending_activation';
|
const isPendingActivation = purchaseStatus?.status === 'pending_activation';
|
||||||
const isFailed = purchaseStatus?.status === 'failed' || purchaseStatus?.status === 'expired';
|
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.
|
// 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.
|
// Recipient arrives via email link with ?activate=1 and sees the activate button instead.
|
||||||
const isGiftPendingActivation = isPendingActivation && purchaseStatus?.is_gift && !isActivateHint;
|
const isGiftPendingActivation = isPendingActivation && purchaseStatus?.is_gift && !isActivateHint;
|
||||||
@@ -672,6 +791,15 @@ export default function PurchaseSuccess() {
|
|||||||
>
|
>
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<FailedState />
|
<FailedState />
|
||||||
|
) : isBuyerGiftLink ? (
|
||||||
|
<GiftLinkShareState
|
||||||
|
claimUrl={purchaseStatus.claim_url}
|
||||||
|
botClaimLink={purchaseStatus.bot_claim_link}
|
||||||
|
tariffName={purchaseStatus.tariff_name}
|
||||||
|
periodDays={purchaseStatus.period_days}
|
||||||
|
recipientContactValue={purchaseStatus.recipient_contact_value}
|
||||||
|
contactType={purchaseStatus.contact_type}
|
||||||
|
/>
|
||||||
) : isEmailSelfPurchase ? (
|
) : isEmailSelfPurchase ? (
|
||||||
<CabinetCredentialsState
|
<CabinetCredentialsState
|
||||||
cabinetEmail={purchaseStatus.cabinet_email!}
|
cabinetEmail={purchaseStatus.cabinet_email!}
|
||||||
|
|||||||
Reference in New Issue
Block a user