From 1bafcca1ef58d98f044575511bbccf2c17825aa2 Mon Sep 17 00:00:00 2001 From: Fringg Date: Tue, 10 Mar 2026 06:03:28 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20gift=20UI=20improvements=20=E2=80=94=20d?= =?UTF-8?q?eclension,=20GB=20display,=20share=20modal,=20deep=20links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix Russian device count declension (_one/_few/_many) - Show traffic GB and tariff description in buy tab - Replace copy/share buttons with share modal (bot + cabinet links) - GiftResult: code-only success state with formatted share message - Support ?tab=activate&code=TOKEN for auto-activation via cabinet link - Add is_code_only + purchase_token to API types --- src/api/gift.ts | 2 + src/locales/en.json | 14 +- src/locales/ru.json | 23 +++- src/pages/GiftResult.tsx | 165 ++++++++++++++++++++++- src/pages/GiftSubscription.tsx | 235 +++++++++++++++++++++++++-------- 5 files changed, 380 insertions(+), 59 deletions(-) diff --git a/src/api/gift.ts b/src/api/gift.ts index a009b5a..72c7d89 100644 --- a/src/api/gift.ts +++ b/src/api/gift.ts @@ -70,6 +70,8 @@ export type GiftPurchaseStatusValue = export interface GiftPurchaseStatus { status: GiftPurchaseStatusValue; is_gift: boolean; + is_code_only: boolean; + purchase_token: string | null; recipient_contact_value: string | null; gift_message: string | null; tariff_name: string | null; diff --git a/src/locales/en.json b/src/locales/en.json index 6d9951d..e5b53d8 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -4233,9 +4233,21 @@ "codeCopied": "Code copied!", "shareGift": "SHARE", "shareText": "I have a gift for you! Activate it here:", + "codeReadyTitle": "Gift code is ready!", + "codeLabel": "Gift code", + "sharePreview": "Message to share", + "copyMessage": "Copy", "activatedBy": "Activated by {{username}}", "sentTo": "Sent to {{recipient}}", "daysShort": "days", - "devicesShort": "dev." + "devicesShort": "dev.", + "gbShort": "GB", + "unlimitedTraffic": "Unlimited", + "shareModalTitle": "Share gift", + "shareModalDesc": "Send this message to the gift recipient", + "shareModalActivateVia": "Activate via bot:", + "shareModalActivateViaCabinet": "Or via website:", + "shareModalCopyAll": "Copy message", + "shareModalCopied": "Copied!" } } diff --git a/src/locales/ru.json b/src/locales/ru.json index fb98fb2..0e12e78 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -4770,8 +4770,10 @@ "tabMyGifts": "Мои подарки", "selectTariff": "ВЫБЕРИТЕ ТАРИФ", "selectPeriod": "ПЕРИОД ПОДПИСКИ", - "deviceCount": "{{count}} устройство", - "deviceCount_other": "{{count}} устройств", + "deviceCount": "{{count}} устройств", + "deviceCount_one": "{{count}} устройство", + "deviceCount_few": "{{count}} устройства", + "deviceCount_many": "{{count}} устройств", "activateTitle": "Введите код подарка", "activateDescription": "Введите код подарка, полученный от друга или знакомого", "activateCodePlaceholder": "GIFT-XXXXXXXXXXXX", @@ -4795,9 +4797,24 @@ "codeCopied": "Код скопирован!", "shareGift": "ПОДЕЛИТЬСЯ", "shareText": "У меня есть подарок для тебя! Активируй его здесь:", + "codeReadyTitle": "Код подарка готов!", + "codeLabel": "Код подарка", + "sharePreview": "Сообщение для отправки", + "copyMessage": "Копировать", "activatedBy": "Активирован пользователем {{username}}", "sentTo": "Отправлен {{recipient}}", "daysShort": "дн.", - "devicesShort": "устр." + "devicesShort": "устр.", + "devicesShort_one": "устр.", + "devicesShort_few": "устр.", + "devicesShort_many": "устр.", + "gbShort": "ГБ", + "unlimitedTraffic": "Безлимит", + "shareModalTitle": "Поделиться подарком", + "shareModalDesc": "Отправьте это сообщение получателю подарка", + "shareModalActivateVia": "Активировать через бота:", + "shareModalActivateViaCabinet": "Или через сайт:", + "shareModalCopyAll": "Скопировать сообщение", + "shareModalCopied": "Скопировано!" } } diff --git a/src/pages/GiftResult.tsx b/src/pages/GiftResult.tsx index 6193e16..4828145 100644 --- a/src/pages/GiftResult.tsx +++ b/src/pages/GiftResult.tsx @@ -7,6 +7,11 @@ import { giftApi } from '../api/gift'; import { Spinner } from '@/components/ui/Spinner'; import { AnimatedCheckmark } from '@/components/ui/AnimatedCheckmark'; import { AnimatedCrossmark } from '@/components/ui/AnimatedCrossmark'; +import { cn } from '@/lib/utils'; + +function formatGiftCode(token: string): string { + return `GIFT-${token.slice(0, 12).toUpperCase()}`; +} const MAX_POLL_MS = 10 * 60 * 1000; // 10 minutes @@ -38,6 +43,154 @@ function PendingState() { ); } +function CodeOnlySuccessState({ + purchaseToken, + tariffName, + periodDays, +}: { + purchaseToken: string; + tariffName: string | null; + periodDays: number | null; +}) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [copied, setCopied] = useState(false); + + const giftCode = formatGiftCode(purchaseToken); + const botUsername = import.meta.env.VITE_BOT_USERNAME as string | undefined; + const botLink = botUsername + ? `https://t.me/${botUsername}?start=GIFTCODE_${purchaseToken}` + : null; + const cabinetLink = `${window.location.origin}/gift?tab=activate&code=${purchaseToken}`; + + const fullMessage = [ + t('gift.shareText', 'I have a gift for you! Activate it here:'), + '', + botLink ? `${t('gift.shareModalActivateVia', 'Activate via bot:')} ${botLink}` : null, + `${t('gift.shareModalActivateViaCabinet', 'Or via website:')} ${cabinetLink}`, + ] + .filter(Boolean) + .join('\n'); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(fullMessage); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // fallback + } + }; + + return ( + + + +
+

+ {t('gift.codeReadyTitle', 'Gift code is ready!')} +

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

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

+ )} +
+ + {/* Gift code display */} +
+

+ {t('gift.codeLabel', 'Gift code')} +

+

{giftCode}

+
+ + {/* Share message preview */} +
+

+ {t('gift.shareText', 'I have a gift for you! Activate it here:')} +

+ + {botLink && ( +
+

+ {t('gift.shareModalActivateVia', 'Activate via bot:')} +

+

+ {botLink} +

+
+ )} + +
+

+ {t('gift.shareModalActivateViaCabinet', 'Or via website:')} +

+

+ {cabinetLink} +

+
+
+ + {/* Copy button */} + + + +
+ ); +} + function DeliveredState({ recipientContact, tariffName, @@ -375,9 +528,12 @@ export default function GiftResult() { // Balance mode: fetch once, no polling if (isBalanceMode) return false; - const s = query.state.data?.status; + const d = query.state.data; + const s = d?.status; if (s === 'delivered' || s === 'failed' || s === 'pending_activation' || s === 'expired') return false; + // Code-only gifts stay in 'paid' status — stop polling + if (s === 'paid' && d?.is_code_only) return false; // Check poll timeout if (Date.now() - pollStart.current > MAX_POLL_MS) { @@ -411,6 +567,7 @@ export default function GiftResult() { ); } + const isCodeOnlyPaid = status?.status === 'paid' && status?.is_code_only; const isDelivered = status?.status === 'delivered'; const isPendingActivation = status?.status === 'pending_activation'; const isFailed = status?.status === 'failed' || status?.status === 'expired'; @@ -429,6 +586,12 @@ export default function GiftResult() { > {isError ? ( + ) : isCodeOnlyPaid ? ( + ) : isDelivered ? ( + {tariff.traffic_limit_gb > 0 + ? `${tariff.traffic_limit_gb} ${t('gift.gbShort')}` + : t('gift.unlimitedTraffic')} + {' \u2022 '} {t('gift.deviceCount', { count: tariff.device_limit })}

@@ -699,6 +703,13 @@ function BuyTabContent({ )} + {/* Selected tariff description */} + {selectedTariff?.description && ( +
+

{selectedTariff.description}

+
+ )} + {/* Period selection */} {periodsForDisplay.length > 0 && (
@@ -839,10 +850,15 @@ function BuyTabContent({ // Sub-components: Activate Tab // ============================================================ -function ActivateTabContent() { +function ActivateTabContent({ initialCode }: { initialCode?: string | null }) { const { t } = useTranslation(); const queryClient = useQueryClient(); - const [code, setCode] = useState(''); + const [code, setCode] = useState(initialCode?.toUpperCase() ?? ''); + + // Sync when initialCode changes (e.g. URL param update while tab is active) + useEffect(() => { + if (initialCode) setCode(initialCode.toUpperCase()); + }, [initialCode]); const [activateError, setActivateError] = useState(null); const activateMutation = useMutation({ @@ -949,10 +965,154 @@ function ActivateTabContent() { // Sub-components: My Gifts Tab // ============================================================ -function SentGiftCard({ gift }: { gift: SentGift }) { +function ShareModal({ gift, onClose }: { gift: SentGift; onClose: () => void }) { const { t } = useTranslation(); const [copied, setCopied] = useState(false); + const botUsername = import.meta.env.VITE_BOT_USERNAME as string | undefined; + const botLink = botUsername ? `https://t.me/${botUsername}?start=GIFTCODE_${gift.token}` : null; + const cabinetLink = `${window.location.origin}/gift?tab=activate&code=${gift.token}`; + + const fullMessage = [ + t('gift.shareText'), + '', + botLink ? `${t('gift.shareModalActivateVia')} ${botLink}` : null, + `${t('gift.shareModalActivateViaCabinet')} ${cabinetLink}`, + ] + .filter(Boolean) + .join('\n'); + + const handleCopyAll = async () => { + try { + await navigator.clipboard.writeText(fullMessage); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // fallback + } + }; + + return ( + + {/* Backdrop */} +
+ + {/* Modal content */} + e.stopPropagation()} + > + {/* Header */} +
+
+
+ +
+
+

{t('gift.shareModalTitle')}

+

{t('gift.shareModalDesc')}

+
+
+ +
+ + {/* Message preview */} +
+
+

{t('gift.shareText')}

+ + {botLink && ( +
+

+ {t('gift.shareModalActivateVia')} +

+ + {botLink} + +
+ )} + +
+

+ {t('gift.shareModalActivateViaCabinet')} +

+ + {cabinetLink} + +
+
+
+ + {/* Actions */} +
+ +
+
+ + ); +} + +function SentGiftCard({ gift }: { gift: SentGift }) { + const { t } = useTranslation(); + const [showShareModal, setShowShareModal] = useState(false); + const giftCode = `GIFT-${gift.token.slice(0, 12).toUpperCase()}`; const isActivated = isGiftActivated(gift); const isAvailable = !isActivated && isGiftAvailable(gift.status); @@ -963,40 +1123,6 @@ function SentGiftCard({ gift }: { gift: SentGift }) { ? t('gift.statusAvailable') : t(getGiftStatusKey(gift.status)); - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(gift.token); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - // Clipboard API not available - } - }; - - const handleShare = async () => { - const botUsername = import.meta.env.VITE_BOT_USERNAME as string | undefined; - if (!botUsername) return; - const shareUrl = `https://t.me/${botUsername}?start=GIFTCODE_${gift.token}`; - const shareText = t('gift.shareText'); - - if (navigator.share) { - try { - await navigator.share({ - text: `${shareText} ${shareUrl}`, - }); - return; - } catch { - // User cancelled or not available — fallback to clipboard - } - } - - try { - await navigator.clipboard.writeText(shareUrl); - } catch { - // Clipboard API not available - } - }; - return (
{/* Header: tariff name + status badge */} @@ -1022,7 +1148,7 @@ function SentGiftCard({ gift }: { gift: SentGift }) { {' \u2022 '} {gift.period_days} {t('gift.daysShort')} {' \u2022 '} - {gift.device_limit} {t('gift.devicesShort')} + {gift.device_limit} {t('gift.devicesShort', { count: gift.device_limit })}

{/* Gift code + actions (only when not activated) */} @@ -1035,20 +1161,10 @@ function SentGiftCard({ gift }: { gift: SentGift }) {

- {/* Copy button */} + {/* Share button — opens modal */} - - {/* Share button */} -
); } @@ -1096,7 +1217,7 @@ function ReceivedGiftCard({ gift }: { gift: ReceivedGift }) { {' \u2022 '} {gift.period_days} {t('gift.daysShort')} {' \u2022 '} - {gift.device_limit} {t('gift.devicesShort')} + {gift.device_limit} {t('gift.devicesShort', { count: gift.device_limit })}

{/* Sender */} @@ -1221,8 +1342,14 @@ const tabContentVariants = { export default function GiftSubscription() { const { t } = useTranslation(); + const [searchParams] = useSearchParams(); - const [activeTab, setActiveTab] = useState('buy'); + // URL params: ?tab=activate&code=TOKEN for auto-activation + const urlTab = searchParams.get('tab') as TabId | null; + const urlCode = searchParams.get('code'); + const [activeTab, setActiveTab] = useState( + urlTab === 'activate' || urlTab === 'myGifts' ? urlTab : 'buy', + ); // Fetch config const { @@ -1319,7 +1446,7 @@ export default function GiftSubscription() { {activeTab === 'buy' && ( setActiveTab('myGifts')} /> )} - {activeTab === 'activate' && } + {activeTab === 'activate' && } {activeTab === 'myGifts' && }