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:
c0mrade
2026-06-03 13:46:00 +03:00
parent 71047780c9
commit db31d395cd
8 changed files with 579 additions and 1 deletions

View File

@@ -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={

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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": "به عنوان هدیه",

View File

@@ -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": "В подарок",

View File

@@ -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
View 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">&ldquo;{gift.gift_message}&rdquo;</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>
);
}

View File

@@ -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!}