import { useState, useMemo, useEffect, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { useNavigate, useSearchParams, Link } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { motion, AnimatePresence } from 'framer-motion'; import { giftApi } from '../api/gift'; import type { GiftConfig, GiftTariff, GiftTariffPeriod, GiftPaymentMethod, GiftPurchaseRequest, SentGift, ReceivedGift, } from '../api/gift'; import { cn } from '../lib/utils'; import { copyToClipboard } from '../utils/clipboard'; import { getApiErrorMessage } from '../utils/api-error'; import { formatPrice } from '../utils/format'; import { usePlatform, useHaptic } from '@/platform'; function GiftIcon({ className }: { className?: string }) { return ( ); } function CheckIcon({ className }: { className?: string }) { return ( ); } function ShareIcon({ className }: { className?: string }) { return ( ); } function CheckCircleIcon({ className }: { className?: string }) { return ( ); } function KeyIcon({ className }: { className?: string }) { return ( ); } function InboxIcon({ className }: { className?: string }) { return ( ); } function formatPeriodLabel( days: number, t: (key: string, options?: Record) => string, ): string { const key = `landing.periodLabels.d${days}`; const result = t(key); if (result !== key) return result; const months = Math.floor(days / 30); const remainder = days % 30; if (months > 0 && remainder === 0) { return t('landing.periodLabels.nMonths', { count: months }); } return t('landing.periodLabels.nDays', { count: days }); } function getGiftStatusKey(status: string): string { const statusMap: Record = { pending_activation: 'gift.statusPendingActivation', delivered: 'gift.statusDelivered', paid: 'gift.statusAvailable', pending: 'gift.statusPending', failed: 'gift.statusFailed', expired: 'gift.statusExpired', }; return statusMap[status] ?? 'gift.statusPending'; } function isGiftAvailable(status: string): boolean { return status === 'paid' || status === 'delivered' || status === 'pending_activation'; } function isGiftActivated(gift: SentGift): boolean { return gift.status === 'delivered' && gift.activated_by_username != null; } function formatGiftDate(dateStr: string | null): string { if (!dateStr) return ''; const date = new Date(dateStr); return date.toLocaleDateString(navigator.language || 'ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', }); } type TabId = 'buy' | 'activate' | 'myGifts'; function LoadingSkeleton() { return (
); } function ErrorState({ message }: { message: string }) { const { t } = useTranslation(); return (

{t('gift.failedTitle')}

{message}

); } function DisabledState() { const { t } = useTranslation(); const navigate = useNavigate(); useEffect(() => { const timer = setTimeout(() => navigate('/'), 3000); return () => clearTimeout(timer); }, [navigate]); return (

{t('gift.featureDisabled')}

{t('gift.redirecting')}

); } function TariffCard({ tariff, isSelected, onSelect, }: { tariff: GiftTariff; isSelected: boolean; onSelect: () => void; }) { const { t } = useTranslation(); return ( ); } function PeriodCard({ period, isSelected, onSelect, }: { period: GiftTariffPeriod; isSelected: boolean; onSelect: () => void; }) { const { t } = useTranslation(); const hasDiscount = period.original_price_kopeks != null && period.original_price_kopeks > period.price_kopeks; return ( ); } function PaymentModeToggle({ mode, onToggle, balanceLabel, }: { mode: 'balance' | 'gateway'; onToggle: (mode: 'balance' | 'gateway') => void; balanceLabel: string; }) { const { t } = useTranslation(); return (
); } function PaymentMethodCard({ method, isSelected, selectedSubOption, onSelect, onSelectSubOption, }: { method: GiftPaymentMethod; isSelected: boolean; selectedSubOption: string | null; onSelect: () => void; onSelectSubOption: (subOptionId: string) => void; }) { const hasSubOptions = method.sub_options && method.sub_options.length > 1; return (
{isSelected && hasSubOptions && (
{method.sub_options!.map((opt) => ( ))}
)}
); } function BuyTabContent({ config, onPurchaseComplete, }: { config: GiftConfig; onPurchaseComplete: () => void; }) { const { t } = useTranslation(); const queryClient = useQueryClient(); const { openInvoice, capabilities } = usePlatform(); const haptic = useHaptic(); // Selection state const [selectedTariffId, setSelectedTariffId] = useState(null); const [selectedPeriodDays, setSelectedPeriodDays] = useState(null); const [paymentMode, setPaymentMode] = useState<'balance' | 'gateway'>('balance'); const [selectedMethod, setSelectedMethod] = useState(null); const [selectedSubOption, setSelectedSubOption] = useState(null); const [submitError, setSubmitError] = useState(null); // Collect ALL unique periods across ALL tariffs const allPeriods = useMemo(() => { const periodMap = new Map(); for (const tariff of config.tariffs) { for (const period of tariff.periods) { if (!periodMap.has(period.days)) { periodMap.set(period.days, period); } } } return Array.from(periodMap.values()).sort((a, b) => a.days - b.days); }, [config]); // Filter tariffs to only those that have the selected period const visibleTariffs = useMemo(() => { if (!selectedPeriodDays) return config.tariffs; return config.tariffs.filter((tariff) => tariff.periods.some((p) => p.days === selectedPeriodDays), ); }, [config, selectedPeriodDays]); // Auto-select first tariff, period, method on config load useEffect(() => { if (allPeriods.length > 0 && selectedPeriodDays === null) { setSelectedPeriodDays(allPeriods[0].days); } if (visibleTariffs.length > 0 && selectedTariffId === null) { setSelectedTariffId(visibleTariffs[0].id); } if (config.payment_methods.length > 0 && selectedMethod === null) { const firstMethod = config.payment_methods[0]; setSelectedMethod(firstMethod.method_id); if (firstMethod.sub_options && firstMethod.sub_options.length >= 1) { setSelectedSubOption(firstMethod.sub_options[0].id); } else { setSelectedSubOption(null); } } }, [config, allPeriods, visibleTariffs, selectedTariffId, selectedPeriodDays, selectedMethod]); // When period changes, auto-select first visible tariff if current is hidden useEffect(() => { if (!visibleTariffs.length) return; const currentVisible = visibleTariffs.find((tariff) => tariff.id === selectedTariffId); if (!currentVisible) { setSelectedTariffId(visibleTariffs[0].id); } }, [visibleTariffs, selectedTariffId]); // Derived data const selectedTariff = useMemo( () => config.tariffs.find((tr) => tr.id === selectedTariffId), [config.tariffs, selectedTariffId], ); const selectedPeriod = useMemo( () => selectedTariff?.periods.find((p) => p.days === selectedPeriodDays), [selectedTariff, selectedPeriodDays], ); const currentPrice = selectedPeriod?.price_kopeks ?? 0; const insufficientBalance = paymentMode === 'balance' && config.balance_kopeks < currentPrice; // Validation const canSubmit = useMemo(() => { if (!selectedTariffId || !selectedPeriodDays) return false; if (paymentMode === 'gateway' && !selectedMethod) return false; if (insufficientBalance) return false; return true; }, [selectedTariffId, selectedPeriodDays, paymentMode, selectedMethod, insufficientBalance]); // Purchase mutation const purchaseMutation = useMutation({ mutationFn: (data: GiftPurchaseRequest) => giftApi.createPurchase(data), onSuccess: async (result) => { if (result.payment_url) { // Telegram Stars: open invoice natively instead of redirect const isStars = selectedMethod === 'telegram_stars'; if (isStars && capabilities.hasInvoice) { try { const status = await openInvoice(result.payment_url); if (status === 'paid') { haptic.notification('success'); queryClient.invalidateQueries({ queryKey: ['balance'] }); queryClient.invalidateQueries({ queryKey: ['gift-config'] }); queryClient.invalidateQueries({ queryKey: ['gift-sent'] }); onPurchaseComplete(); } else if (status === 'failed') { haptic.notification('error'); setSubmitError(t('gift.failedDesc')); } // 'cancelled' — user closed the invoice, do nothing } catch (e) { setSubmitError(t('gift.failedDesc')); } return; } window.location.href = result.payment_url; } else { // Balance purchase: switch to MyGifts tab so the new code is visible queryClient.invalidateQueries({ queryKey: ['balance'] }); queryClient.invalidateQueries({ queryKey: ['gift-config'] }); queryClient.invalidateQueries({ queryKey: ['gift-sent'] }); onPurchaseComplete(); } }, onError: (err) => { const msg = getApiErrorMessage(err, t('gift.failedDesc')); setSubmitError(msg); }, }); // Submit handler const handleSubmit = () => { if (!selectedTariffId || !selectedPeriodDays || !canSubmit || purchaseMutation.isPending) return; setSubmitError(null); let paymentMethod: string | undefined; if (paymentMode === 'gateway' && selectedMethod) { paymentMethod = selectedMethod; if (selectedSubOption) { paymentMethod = `${paymentMethod}_${selectedSubOption}`; } } const data: GiftPurchaseRequest = { tariff_id: selectedTariffId, period_days: selectedPeriodDays, payment_mode: paymentMode, payment_method: paymentMethod, }; purchaseMutation.mutate(data); }; // Balance label with current amount const balanceLabel = useMemo(() => { return `${t('gift.fromBalance')} (${formatPrice(config.balance_kopeks)})`; }, [config, t]); const showTariffCards = visibleTariffs.length > 1; // Periods for the selected tariff (for period cards) const periodsForDisplay = useMemo(() => { if (selectedTariff) { return [...selectedTariff.periods].sort((a, b) => a.days - b.days); } return allPeriods; }, [selectedTariff, allPeriods]); return (
{/* Tariff selection */} {showTariffCards && (

{t('gift.selectTariff')}

{visibleTariffs.map((tariff) => ( setSelectedTariffId(tariff.id)} /> ))}
)} {/* Selected tariff description */} {selectedTariff?.description && (

{selectedTariff.description}

)} {/* Promo group banner */} {config.promo_group_name && (
{t('subscription.promoGroup.yourGroup', { name: config.promo_group_name })}
{t('subscription.promoGroup.personalDiscountsApplied')}
)} {/* Active discount banner */} {config.active_discount_percent != null && config.active_discount_percent > 0 && (
{t('promo.discountApplied')} -{config.active_discount_percent}%
)} {/* Period selection */} {periodsForDisplay.length > 0 && (

{t('gift.selectPeriod')}

{periodsForDisplay.map((period) => ( setSelectedPeriodDays(period.days)} /> ))}
)} {/* Payment mode toggle */}

{t('gift.paymentMode')}

{/* Payment method cards (gateway mode only) */} {paymentMode === 'gateway' && config.payment_methods.length > 0 && (
{config.payment_methods.map((method) => ( { setSelectedMethod(method.method_id); if (method.sub_options && method.sub_options.length >= 1) { setSelectedSubOption(method.sub_options[0].id); } else { setSelectedSubOption(null); } }} onSelectSubOption={setSelectedSubOption} /> ))}
)}
{/* Summary / Balance info */} {paymentMode === 'balance' && (
{t('gift.yourBalance')} {formatPrice(config.balance_kopeks)}
)} {/* Insufficient balance warning */} {insufficientBalance && (

{t('gift.insufficientBalance')}{' '} {t('gift.topUpBalance')}

)}
{/* Error */} {submitError && (

{submitError}

)}
{/* Submit button */}
); } function ActivateTabContent({ initialCode }: { initialCode?: string | null }) { const { t } = useTranslation(); const queryClient = useQueryClient(); const [code, setCode] = useState(initialCode ?? ''); // Sync when initialCode changes (e.g. URL param update while tab is active) useEffect(() => { if (initialCode) setCode(initialCode); }, [initialCode]); const [activateError, setActivateError] = useState(null); const activateMutation = useMutation({ mutationFn: (giftCode: string) => giftApi.activateGiftCode(giftCode), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['gift-received'] }); queryClient.invalidateQueries({ queryKey: ['gift-sent'] }); queryClient.invalidateQueries({ queryKey: ['balance'] }); }, onError: (err) => { const raw = getApiErrorMessage(err, ''); const msg = raw === 'Cannot activate your own gift' ? t('gift.activateSelfError') : raw || t('gift.activateError'); setActivateError(msg); }, }); const handleActivate = () => { const trimmed = code.trim(); if (!trimmed || activateMutation.isPending) return; setActivateError(null); activateMutation.mutate(trimmed); }; if (activateMutation.isSuccess && activateMutation.data) { const result = activateMutation.data; return (

{t('gift.activateSuccess')}

{t('gift.activateSuccessDesc', { tariff: result.tariff_name ?? '', days: result.period_days ?? 0, })}

); } return (
{/* Icon + title */}

{t('gift.activateTitle')}

{t('gift.activateDescription')}

{/* Code input */}
{ setCode(e.target.value); setActivateError(null); }} placeholder={t('gift.activateCodePlaceholder')} className="w-full rounded-2xl border border-dark-700/50 bg-dark-800/50 px-6 py-4 text-center font-mono text-sm text-dark-50 placeholder-dark-500 outline-none transition-colors focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/25" aria-label={t('gift.activateTitle')} />
{/* Error */} {activateError && (

{activateError}

)}
{/* Submit */}
); } function CopiedToast({ onDismiss }: { onDismiss: () => void }) { const { t } = useTranslation(); useEffect(() => { const timer = setTimeout(onDismiss, 2000); return () => clearTimeout(timer); }, [onDismiss]); return (
{t('gift.shareToastCopied')}
); } function SentGiftCard({ gift }: { gift: SentGift }) { const { t } = useTranslation(); const [showToast, setShowToast] = useState(false); const shortCode = gift.token.slice(0, 12); const giftCode = `GIFT-${shortCode}`; const isActivated = isGiftActivated(gift); const isAvailable = !isActivated && isGiftAvailable(gift.status); const statusText = isActivated ? t('gift.statusActivated') : isAvailable ? t('gift.statusAvailable') : t(getGiftStatusKey(gift.status)); const buildShareMessage = useCallback(() => { const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME as string | undefined; const botLink = botUsername ? `https://t.me/${botUsername}?start=GIFT_${shortCode}` : null; const cabinetLink = `${window.location.origin}/gift?tab=activate&code=${shortCode}`; return [ t('gift.shareText'), '', botLink ? `${t('gift.shareModalActivateVia')} ${botLink}` : null, `${t('gift.shareModalActivateViaCabinet')} ${cabinetLink}`, ] .filter(Boolean) .join('\n'); }, [shortCode, t]); const handleShare = useCallback(async () => { const message = buildShareMessage(); await copyToClipboard(message); setShowToast(true); }, [buildShareMessage]); const handleDismissToast = useCallback(() => setShowToast(false), []); return (
{/* Header: tariff name + status badge */}

{gift.tariff_name ?? t('gift.tariff')}

{statusText}
{/* Info line */}

{formatGiftDate(gift.created_at)} {' \u2022 '} {gift.period_days} {t('gift.daysShort')} {' \u2022 '} {gift.device_limit} {t('gift.devicesShort', { count: gift.device_limit })}

{/* Gift code + actions (only when not activated) */} {!isActivated && ( <> {/* Gift code display */}

{giftCode}

{/* Share button — copies message and shows toast */} )} {/* Activated by */} {isActivated && gift.activated_by_username && (

{t('gift.activatedBy', { username: gift.activated_by_username })}

)} {/* Sent to */} {gift.gift_recipient_value && (

{t('gift.sentTo', { recipient: gift.gift_recipient_value })}

)} {/* Copied toast — portal to escape motion.div transform context */} {createPortal( {showToast && } , document.body, )}
); } function ReceivedGiftCard({ gift }: { gift: ReceivedGift }) { const { t } = useTranslation(); const statusKey = getGiftStatusKey(gift.status); const statusText = t(statusKey); return (
{/* Header */}

{gift.tariff_name ?? t('gift.tariff')}

{statusText}
{/* Info line */}

{formatGiftDate(gift.created_at)} {' \u2022 '} {gift.period_days} {t('gift.daysShort')} {' \u2022 '} {gift.device_limit} {t('gift.devicesShort', { count: gift.device_limit })}

{/* Sender */} {gift.sender_display && (

{t('gift.pending.from', { sender: gift.sender_display })}

)} {/* Gift message */} {gift.gift_message && (

{gift.gift_message}

)}
); } function MyGiftsTabContent() { const { t } = useTranslation(); const { data: sentGifts, isLoading: sentLoading, error: sentError, } = useQuery({ queryKey: ['gift-sent'], queryFn: giftApi.getSentGifts, staleTime: 30_000, }); const { data: receivedGifts, isLoading: receivedLoading, error: receivedError, } = useQuery({ queryKey: ['gift-received'], queryFn: giftApi.getReceivedGifts, staleTime: 30_000, }); const isLoading = sentLoading || receivedLoading; // Split sent gifts into active (awaiting activation) and activated const activeGifts = useMemo( () => (sentGifts ?? []).filter((g) => !isGiftActivated(g)), [sentGifts], ); const activatedGifts = useMemo( () => (sentGifts ?? []).filter((g) => isGiftActivated(g)), [sentGifts], ); const hasActive = activeGifts.length > 0; const hasActivated = activatedGifts.length > 0; const hasReceived = receivedGifts && receivedGifts.length > 0; const isEmpty = !hasActive && !hasActivated && !hasReceived; if (isLoading) { return (
); } if (sentError || receivedError) { return (

{t('gift.failedDesc')}

); } if (isEmpty) { return (

{t('gift.myGiftsEmpty')}

{t('gift.myGiftsEmptyDesc')}

); } return (
{/* Active gifts (awaiting activation) */} {hasActive && (

{t('gift.activeGiftsTitle')}

{activeGifts.map((gift) => ( ))}
)} {/* Activated gifts */} {hasActivated && (

{t('gift.activatedGiftsTitle')}

{activatedGifts.map((gift) => ( ))}
)} {/* Received gifts */} {hasReceived && (

{t('gift.receivedGiftsTitle')}

{receivedGifts!.map((gift) => ( ))}
)}
); } const tabContentVariants = { initial: { opacity: 0, x: 20 }, animate: { opacity: 1, x: 0 }, exit: { opacity: 0, x: -20 }, }; export default function GiftSubscription() { const { t } = useTranslation(); const [searchParams] = useSearchParams(); // 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 { data: config, isLoading, error, } = useQuery({ queryKey: ['gift-config'], queryFn: giftApi.getConfig, staleTime: 30_000, }); // Loading state if (isLoading) { return ; } // Error state if (error || !config) { const errMsg = getApiErrorMessage(error, t('gift.notFound')); return ; } // Disabled state if (!config.is_enabled) { return ; } const tabs: { id: TabId; label: string }[] = [ { id: 'buy', label: t('gift.tabBuy') }, { id: 'activate', label: t('gift.tabActivate') }, { id: 'myGifts', label: t('gift.tabMyGifts') }, ]; return (
{/* Header */}

{t('gift.pageTitle')}

{/* Tab bar */}
{tabs.map((tab) => ( ))}
{/* Tab content */} {activeTab === 'buy' && ( setActiveTab('myGifts')} /> )} {activeTab === 'activate' && } {activeTab === 'myGifts' && }
); }