diff --git a/src/App.tsx b/src/App.tsx index f0c973f..295b137 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,6 +29,7 @@ import Dashboard from './pages/Dashboard'; const Subscription = lazy(() => import('./pages/Subscription')); const SubscriptionPurchase = lazy(() => import('./pages/SubscriptionPurchase')); const Balance = lazy(() => import('./pages/Balance')); +const SavedCards = lazy(() => import('./pages/SavedCards')); const Referral = lazy(() => import('./pages/Referral')); const Support = lazy(() => import('./pages/Support')); const Profile = lazy(() => import('./pages/Profile')); @@ -280,6 +281,16 @@ function App() { } /> + + + + + + } + /> => { + const response = await apiClient.get('/cabinet/balance/saved-cards'); + return response.data; + }, + + // Unlink (delete) a saved payment method + deleteSavedCard: async (id: number): Promise => { + await apiClient.delete(`/cabinet/balance/saved-cards/${id}`); + }, }; diff --git a/src/api/gift.ts b/src/api/gift.ts index c96c180..72c7d89 100644 --- a/src/api/gift.ts +++ b/src/api/gift.ts @@ -45,8 +45,8 @@ export interface GiftConfig { export interface GiftPurchaseRequest { tariff_id: number; period_days: number; - recipient_type: 'email' | 'telegram'; - recipient_value: string; + recipient_type?: 'email' | 'telegram'; + recipient_value?: string; gift_message?: string; payment_mode: 'balance' | 'gateway'; payment_method?: string; @@ -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; @@ -86,6 +88,35 @@ export interface PendingGift { created_at: string | null; } +export interface SentGift { + token: string; + tariff_name: string | null; + period_days: number; + device_limit: number; + status: string; + gift_recipient_value: string | null; + gift_message: string | null; + activated_by_username: string | null; + created_at: string | null; +} + +export interface ReceivedGift { + token: string; + tariff_name: string | null; + period_days: number; + device_limit: number; + status: string; + sender_display: string | null; + gift_message: string | null; + created_at: string | null; +} + +export interface ActivateGiftResponse { + status: string; + tariff_name: string | null; + period_days: number | null; +} + // API export const giftApi = { @@ -108,4 +139,19 @@ export const giftApi = { const { data } = await apiClient.get('/cabinet/gift/pending'); return data; }, + + getSentGifts: async (): Promise => { + const { data } = await apiClient.get('/cabinet/gift/sent'); + return data; + }, + + getReceivedGifts: async (): Promise => { + const { data } = await apiClient.get('/cabinet/gift/received'); + return data; + }, + + activateGiftCode: async (code: string): Promise => { + const { data } = await apiClient.post('/cabinet/gift/activate', { code }); + return data; + }, }; diff --git a/src/components/PaymentMethodIcon.tsx b/src/components/PaymentMethodIcon.tsx index 8fd00a4..9609e4c 100644 --- a/src/components/PaymentMethodIcon.tsx +++ b/src/components/PaymentMethodIcon.tsx @@ -1,3 +1,5 @@ +import { useId } from 'react'; + interface PaymentMethodIconProps { method: string; className?: string; @@ -7,6 +9,8 @@ export default function PaymentMethodIcon({ method, className = 'h-8 w-8', }: PaymentMethodIconProps) { + const uid = useId(); + switch (method) { case 'telegram_stars': return ( @@ -171,16 +175,17 @@ export default function PaymentMethodIcon({ ); - case 'kassa_ai': + case 'kassa_ai': { + const kassaGradId = `${uid}-kassaAi`; return ( - + - + AI @@ -188,6 +193,27 @@ export default function PaymentMethodIcon({ ); + } + + case 'riopay': { + const riopayGradId = `${uid}-riopay`; + return ( + + + + + + + + + + + RP + + + + ); + } default: return ( diff --git a/src/components/dashboard/PendingGiftCard.tsx b/src/components/dashboard/PendingGiftCard.tsx index 85240ad..c3d8691 100644 --- a/src/components/dashboard/PendingGiftCard.tsx +++ b/src/components/dashboard/PendingGiftCard.tsx @@ -67,7 +67,7 @@ export default function PendingGiftCard({ gifts, className }: PendingGiftCardPro {/* Activate button */} {t('gift.pending.activate')} diff --git a/src/components/dashboard/SubscriptionCardExpired.tsx b/src/components/dashboard/SubscriptionCardExpired.tsx index ad76403..9b13259 100644 --- a/src/components/dashboard/SubscriptionCardExpired.tsx +++ b/src/components/dashboard/SubscriptionCardExpired.tsx @@ -207,79 +207,92 @@ export default function SubscriptionCardExpired({ {/* Action buttons */}
- {/* Quick Renew or Top Up button */} - {hasBalance ? ( - + ) : ( + )} - {isRenewing - ? t('common.loading') - : isDisabledDaily - ? t('dashboard.suspended.resume') - : t('dashboard.expired.quickRenew')} - - ) : ( - + )} - {/* Renew (go to purchase page) */} + {/* Tariffs (go to purchase page) — full-width for trials */} {t('dashboard.expired.tariffs')} diff --git a/src/components/icons/index.tsx b/src/components/icons/index.tsx index 8b9e26e..3464619 100644 --- a/src/components/icons/index.tsx +++ b/src/components/icons/index.tsx @@ -33,6 +33,18 @@ export const BackIcon = ({ className }: IconProps) => ( ); +export const ChevronRightIcon = ({ className }: IconProps) => ( + + + +); + export const MenuIcon = ({ className }: IconProps) => ( = { - telegram_stars: 'Telegram Stars', - tribute: 'Tribute', - yookassa: 'YooKassa', - cryptobot: 'CryptoBot', - heleket: 'Heleket', - mulenpay: 'Mulenpay', - pal24: 'Pal24', - wata: 'Wata', - platega: 'Platega', - cloudpayments: 'CloudPayments', - freekassa: 'FreeKassa', - kassa_ai: 'Kassa AI', -}; - export function DepositsTab({ params }: DepositsTabProps) { const { t } = useTranslation(); const { formatWithCurrency } = useCurrency(); diff --git a/src/constants/paymentMethods.ts b/src/constants/paymentMethods.ts new file mode 100644 index 0000000..52b6477 --- /dev/null +++ b/src/constants/paymentMethods.ts @@ -0,0 +1,17 @@ +export const METHOD_LABELS: Record = { + telegram_stars: 'Telegram Stars', + tribute: 'Tribute', + cryptobot: 'CryptoBot', + heleket: 'Heleket', + yookassa: 'YooKassa', + mulenpay: 'MulenPay', + pal24: 'PayPalych', + platega: 'Platega', + wata: 'WATA', + freekassa: 'Freekassa', + freekassa_sbp: 'Freekassa СБП', + freekassa_card: 'Freekassa Карта', + cloudpayments: 'CloudPayments', + kassa_ai: 'Kassa AI', + riopay: 'RioPay', +}; diff --git a/src/locales/en.json b/src/locales/en.json index c3175bc..0df50cd 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -675,6 +675,20 @@ "checkStatus": "Check Status", "checking": "Checking..." }, + "savedCards": { + "title": "Saved Cards", + "card": "Card", + "unlink": "Unlink", + "confirmUnlink": "Are you sure you want to unlink this card? Automatic balance top-up will no longer be possible.", + "unlinkSuccess": "Card unlinked successfully", + "unlinkError": "Failed to unlink card", + "linkedAt": "Linked {{date}}", + "noCards": "Card will be saved automatically on next top-up", + "pageTitle": "Saved Cards", + "backToBalance": "Back", + "empty": "No saved cards. A card will be saved automatically on your next top-up.", + "loadError": "Failed to load saved cards. Please try again later." + }, "paymentReady": "Payment link is ready", "clickToOpenPayment": "Click the button below to open the payment page in a new tab", "openPaymentPage": "Open payment page", @@ -2727,6 +2741,7 @@ "devices": "Devices", "actions": "Actions", "extend": "Extend", + "shorten": "Deduct days", "changeTariff": "Change tariff", "cancel": "Cancel", "activate": "Activate", @@ -2745,7 +2760,8 @@ "trafficAdded": "Traffic package added", "expired": "expired", "daysLeft": "d left", - "deviceLimitUpdated": "Device limit updated" + "deviceLimitUpdated": "Device limit updated", + "invalidDays": "Please enter a valid number of days (greater than 0)" }, "balance": { "current": "Current balance", @@ -4201,6 +4217,53 @@ }, "warning": { "telegram_unresolvable": "Could not verify this Telegram username. The recipient may not receive a notification about the gift." - } + }, + "pageTitle": "Gifts", + "tabBuy": "Buy", + "tabActivate": "Activate", + "tabMyGifts": "My Gifts", + "selectTariff": "SELECT TARIFF", + "selectPeriod": "SUBSCRIPTION PERIOD", + "deviceCount": "{{count}} device", + "deviceCount_other": "{{count}} devices", + "activateTitle": "Enter gift code", + "activateDescription": "Enter the gift code you received from a friend", + "activateCodePlaceholder": "GIFT-XXXXXXXXXXXX", + "activateButton": "Activate gift", + "activating": "Activating...", + "activateSuccess": "Gift activated!", + "activateSuccessDesc": "{{tariff}} — {{days}} days added to your subscription", + "activateError": "Failed to activate gift", + "activateSelfError": "You cannot activate your own gift", + "myGiftsEmpty": "No gifts yet", + "myGiftsEmptyDesc": "Buy a gift for a friend or activate a received code", + "sentGiftsTitle": "Sent", + "activeGiftsTitle": "Active gifts", + "activatedGiftsTitle": "Activated", + "receivedGiftsTitle": "Received", + "statusAvailable": "AVAILABLE", + "statusActivated": "ACTIVATED", + "statusPending": "PENDING", + "statusDelivered": "DELIVERED", + "statusFailed": "FAILED", + "statusExpired": "EXPIRED", + "statusPendingActivation": "PENDING ACTIVATION", + "copyCode": "COPY", + "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", + "activatedBy": "Activated by {{username}}", + "sentTo": "Sent to {{recipient}}", + "daysShort": "days", + "devicesShort": "dev.", + "gbShort": "GB", + "unlimitedTraffic": "Unlimited", + "shareModalActivateVia": "Activate via bot:", + "shareModalActivateViaCabinet": "Or via website:", + "copyMessage": "Copy message", + "shareToastCopied": "Message copied" } } diff --git a/src/locales/fa.json b/src/locales/fa.json index 9cab3be..b292fba 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -533,6 +533,26 @@ "noPaymentMethods": "روش پرداخت در دسترس نیست", "selectPaymentMethod": "روش پرداخت را انتخاب کنید", "topUpToComplete": "برای تکمیل خرید شارژ کنید", + "pendingPayments": { + "title": "پرداخت‌های در انتظار", + "pay": "پرداخت", + "checkStatus": "بررسی وضعیت", + "checking": "درحال بررسی..." + }, + "savedCards": { + "title": "کارت‌های ذخیره شده", + "card": "کارت", + "unlink": "حذف", + "confirmUnlink": "آیا مطمئن هستید که می‌خواهید این کارت را حذف کنید؟ شارژ خودکار موجودی دیگر امکان‌پذیر نخواهد بود.", + "unlinkSuccess": "کارت با موفقیت حذف شد", + "unlinkError": "خطا در حذف کارت", + "linkedAt": "متصل شده {{date}}", + "noCards": "کارت به طور خودکار در شارژ بعدی ذخیره می‌شود", + "pageTitle": "کارت‌های ذخیره شده", + "backToBalance": "بازگشت", + "empty": "کارتی ذخیره نشده است. کارت به طور خودکار در شارژ بعدی ذخیره می‌شود.", + "loadError": "خطا در بارگذاری کارت‌ها. لطفاً بعداً دوباره امتحان کنید." + }, "paymentSuccess": { "title": "پرداخت موفق", "message": "موجودی شما با موفقیت شارژ شد. وجوه اکنون در دسترس است." @@ -2355,6 +2375,7 @@ "devices": "دستگاه‌ها", "actions": "عملیات", "extend": "تمدید", + "shorten": "کسر روزها", "changeTariff": "تغییر تعرفه", "cancel": "لغو", "activate": "فعال‌سازی", @@ -2373,7 +2394,8 @@ "trafficAdded": "بسته ترافیک اضافه شد", "expired": "منقضی شده", "daysLeft": "روز باقیمانده", - "deviceLimitUpdated": "محدودیت دستگاه به‌روز شد" + "deviceLimitUpdated": "محدودیت دستگاه به‌روز شد", + "invalidDays": "لطفاً تعداد روز معتبر وارد کنید (بیشتر از ۰)" }, "balance": { "current": "موجودی فعلی", diff --git a/src/locales/ru.json b/src/locales/ru.json index 88b897b..6b02b9e 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -703,6 +703,20 @@ "checkStatus": "Проверить статус", "checking": "Проверка..." }, + "savedCards": { + "title": "Сохранённые карты", + "card": "Карта", + "unlink": "Отвязать", + "confirmUnlink": "Вы уверены, что хотите отвязать эту карту? Автоматическое пополнение баланса с неё станет невозможным.", + "unlinkSuccess": "Карта успешно отвязана", + "unlinkError": "Не удалось отвязать карту", + "linkedAt": "Привязана {{date}}", + "noCards": "Карта будет сохранена автоматически при следующем пополнении", + "pageTitle": "Сохранённые карты", + "backToBalance": "Назад", + "empty": "Нет привязанных карт. Карта будет сохранена автоматически при следующем пополнении.", + "loadError": "Не удалось загрузить сохранённые карты. Попробуйте позже." + }, "paymentReady": "Ссылка на оплату готова", "clickToOpenPayment": "Нажмите кнопку ниже, чтобы открыть страницу оплаты в новой вкладке", "openPaymentPage": "Открыть страницу оплаты", @@ -3248,6 +3262,7 @@ "devices": "Устройств", "actions": "Действия", "extend": "Продлить", + "shorten": "Списать дни", "changeTariff": "Сменить тариф", "cancel": "Отменить", "activate": "Активировать", @@ -3266,7 +3281,8 @@ "trafficAdded": "Пакет трафика добавлен", "expired": "истёк", "daysLeft": "дн. осталось", - "deviceLimitUpdated": "Лимит устройств обновлён" + "deviceLimitUpdated": "Лимит устройств обновлён", + "invalidDays": "Введите корректное количество дней (больше 0)" }, "balance": { "current": "Текущий баланс", @@ -4763,6 +4779,58 @@ }, "warning": { "telegram_unresolvable": "Не удалось проверить этот Telegram-юзернейм. Получатель может не получить уведомление о подарке." - } + }, + "pageTitle": "Подарки", + "tabBuy": "Купить", + "tabActivate": "Активировать", + "tabMyGifts": "Мои подарки", + "selectTariff": "ВЫБЕРИТЕ ТАРИФ", + "selectPeriod": "ПЕРИОД ПОДПИСКИ", + "deviceCount": "{{count}} устройств", + "deviceCount_one": "{{count}} устройство", + "deviceCount_few": "{{count}} устройства", + "deviceCount_many": "{{count}} устройств", + "activateTitle": "Введите код подарка", + "activateDescription": "Введите код подарка, полученный от друга или знакомого", + "activateCodePlaceholder": "GIFT-XXXXXXXXXXXX", + "activateButton": "Активировать подарок", + "activating": "Активация...", + "activateSuccess": "Подарок активирован!", + "activateSuccessDesc": "{{tariff}} — {{days}} дн. добавлено к вашей подписке", + "activateError": "Не удалось активировать подарок", + "activateSelfError": "Нельзя активировать свой собственный подарок", + "myGiftsEmpty": "У вас пока нет подарков", + "myGiftsEmptyDesc": "Купите подарок для друга или активируйте полученный код", + "sentGiftsTitle": "Отправленные", + "activeGiftsTitle": "Активные подарки", + "activatedGiftsTitle": "Активированные", + "receivedGiftsTitle": "Полученные", + "statusAvailable": "ДОСТУПЕН", + "statusActivated": "АКТИВИРОВАН", + "statusPending": "ОЖИДАЕТ", + "statusDelivered": "ДОСТАВЛЕН", + "statusFailed": "ОШИБКА", + "statusExpired": "ПРОСРОЧЕН", + "statusPendingActivation": "ОЖИДАЕТ АКТИВАЦИИ", + "copyCode": "КОПИРОВАТЬ", + "codeCopied": "Код скопирован!", + "shareGift": "ПОДЕЛИТЬСЯ", + "shareText": "У меня есть подарок для тебя! Активируй его здесь:", + "codeReadyTitle": "Код подарка готов!", + "codeLabel": "Код подарка", + "sharePreview": "Сообщение для отправки", + "activatedBy": "Активирован пользователем {{username}}", + "sentTo": "Отправлен {{recipient}}", + "daysShort": "дн.", + "devicesShort": "устр.", + "devicesShort_one": "устр.", + "devicesShort_few": "устр.", + "devicesShort_many": "устр.", + "gbShort": "ГБ", + "unlimitedTraffic": "Безлимит", + "shareModalActivateVia": "Активировать через бота:", + "shareModalActivateViaCabinet": "Или через сайт:", + "copyMessage": "Скопировать сообщение", + "shareToastCopied": "Сообщение скопировано" } } diff --git a/src/locales/zh.json b/src/locales/zh.json index d6b8206..d5b4e1b 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -533,6 +533,26 @@ "noPaymentMethods": "支付方式不可用", "selectPaymentMethod": "选择支付方式", "topUpToComplete": "充值以完成购买", + "pendingPayments": { + "title": "待支付", + "pay": "支付", + "checkStatus": "检查状态", + "checking": "检查中..." + }, + "savedCards": { + "title": "已保存的银行卡", + "card": "银行卡", + "unlink": "解绑", + "confirmUnlink": "确定要解绑此卡吗?解绑后将无法自动充值余额。", + "unlinkSuccess": "银行卡已成功解绑", + "unlinkError": "解绑失败", + "linkedAt": "绑定于 {{date}}", + "noCards": "银行卡将在下次充值时自动保存", + "pageTitle": "已保存的银行卡", + "backToBalance": "返回", + "empty": "暂无已保存的银行卡。银行卡将在下次充值时自动保存。", + "loadError": "加载银行卡失败,请稍后重试。" + }, "paymentSuccess": { "title": "支付成功", "message": "您的余额已成功充值,资金现已可用。" @@ -2354,6 +2374,7 @@ "devices": "设备数", "actions": "操作", "extend": "延长", + "shorten": "扣除天数", "changeTariff": "更换套餐", "cancel": "取消", "activate": "激活", @@ -2372,7 +2393,8 @@ "trafficAdded": "流量包已添加", "expired": "已过期", "daysLeft": "天剩余", - "deviceLimitUpdated": "设备限制已更新" + "deviceLimitUpdated": "设备限制已更新", + "invalidDays": "请输入有效天数(大于0)" }, "balance": { "current": "当前余额", diff --git a/src/pages/AdminPaymentMethodEdit.tsx b/src/pages/AdminPaymentMethodEdit.tsx index a0ebdc4..2f76918 100644 --- a/src/pages/AdminPaymentMethodEdit.tsx +++ b/src/pages/AdminPaymentMethodEdit.tsx @@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { adminPaymentMethodsApi } from '../api/adminPaymentMethods'; +import { METHOD_LABELS } from '../constants/paymentMethods'; import type { PromoGroupSimple } from '../types'; import { usePlatform } from '../platform/hooks/usePlatform'; import { createNumberInputHandler, toNumber } from '../utils/inputHelpers'; @@ -18,23 +19,6 @@ const BackIcon = () => ( ); -const METHOD_LABELS: Record = { - telegram_stars: 'Telegram Stars', - tribute: 'Tribute', - cryptobot: 'CryptoBot', - heleket: 'Heleket', - yookassa: 'YooKassa', - mulenpay: 'MulenPay', - pal24: 'PayPalych', - platega: 'Platega', - wata: 'WATA', - freekassa: 'Freekassa', - freekassa_sbp: 'Freekassa СБП', - freekassa_card: 'Freekassa Карта', - cloudpayments: 'CloudPayments', - kassa_ai: 'Kassa AI', -}; - const CheckIcon = () => ( diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index 2399957..7bf378d 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -430,12 +430,16 @@ export default function AdminUserDetail() { const handleUpdateSubscription = async (overrideAction?: string) => { if (!userId) return; + const action = overrideAction || subAction; + if ((action === 'extend' || action === 'shorten') && toNumber(subDays, 0) <= 0) { + notify.error(t('admin.users.detail.subscription.invalidDays')); + return; + } setActionLoading(true); try { - const action = overrideAction || subAction; const data: UpdateSubscriptionRequest = { action: action as UpdateSubscriptionRequest['action'], - ...(action === 'extend' ? { days: toNumber(subDays, 30) } : {}), + ...(action === 'extend' || action === 'shorten' ? { days: toNumber(subDays, 30) } : {}), ...(action === 'change_tariff' && selectedTariffId ? { tariff_id: selectedTariffId } : {}), ...(action === 'create' ? { @@ -1374,6 +1378,9 @@ export default function AdminUserDetail() { + @@ -1385,7 +1392,7 @@ export default function AdminUserDetail() { - {subAction === 'extend' && ( + {(subAction === 'extend' || subAction === 'shorten') && (
+
+ 💳 + {t('balance.savedCards.title')} +
+ +
+ + + )} ); } diff --git a/src/pages/GiftResult.tsx b/src/pages/GiftResult.tsx index 6193e16..d647223 100644 --- a/src/pages/GiftResult.tsx +++ b/src/pages/GiftResult.tsx @@ -7,6 +7,7 @@ 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'; const MAX_POLL_MS = 10 * 60 * 1000; // 10 minutes @@ -38,6 +39,153 @@ 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 shortCode = purchaseToken.slice(0, 12); + const giftCode = `GIFT-${shortCode}`; + 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}`; + + 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 +523,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 +562,8 @@ export default function GiftResult() { ); } + const isCodeOnlyPaid = + status?.status === 'paid' && status?.is_code_only && status?.purchase_token != null; const isDelivered = status?.status === 'delivered'; const isPendingActivation = status?.status === 'pending_activation'; const isFailed = status?.status === 'failed' || status?.status === 'expired'; @@ -429,6 +582,12 @@ export default function GiftResult() { > {isError ? ( + ) : isCodeOnlyPaid ? ( + ) : isDelivered ? ( + + + + + + + ); } -function isValidContact(value: string): boolean { - const trimmed = value.trim(); - if (!trimmed) return false; - if (trimmed.startsWith('@')) { - return /^@[a-zA-Z][a-zA-Z0-9_]{4,31}$/.test(trimmed); - } - return /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(trimmed); +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 ( + + + + + ); +} + +// ============================================================ +// Helpers +// ============================================================ + function formatPeriodLabel( days: number, t: (key: string, options?: Record) => string, @@ -49,8 +150,44 @@ function formatPeriodLabel( 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', + }); +} + // ============================================================ -// Sub-components +// Tab type +// ============================================================ + +type TabId = 'buy' | 'activate' | 'myGifts'; + +// ============================================================ +// Sub-components: Shared // ============================================================ function LoadingSkeleton() { @@ -84,7 +221,7 @@ function ErrorState({ message }: { message: string }) { />
-

{t('gift.failedTitle', 'Error')}

+

{t('gift.failedTitle')}

{message}

@@ -118,56 +255,24 @@ function DisabledState() { /> -

- {t('gift.featureDisabled', 'Gift subscriptions are currently unavailable')} -

-

{t('gift.redirecting', 'Redirecting...')}

+

{t('gift.featureDisabled')}

+

{t('gift.redirecting')}

); } -function PeriodTabs({ - periods, - selectedDays, - onSelect, -}: { - periods: GiftTariffPeriod[]; - selectedDays: number; - onSelect: (days: number) => void; -}) { - const { t } = useTranslation(); - - return ( -
- {periods.map((period) => ( - - ))} -
- ); -} +// ============================================================ +// Sub-components: Buy Tab +// ============================================================ function TariffCard({ tariff, isSelected, - selectedPeriod, onSelect, }: { tariff: GiftTariff; isSelected: boolean; - selectedPeriod: GiftTariffPeriod | undefined; onSelect: () => void; }) { const { t } = useTranslation(); @@ -179,100 +284,109 @@ function TariffCard({ aria-checked={isSelected} onClick={onSelect} className={cn( - 'relative flex w-full flex-col rounded-2xl border p-5 text-start transition-all duration-200', + 'flex w-full items-center gap-4 rounded-2xl border p-4 text-start transition-all duration-200', isSelected - ? 'border-accent-500/50 bg-accent-500/5 ring-1 ring-accent-500/25' - : 'border-dark-800/50 bg-dark-900/50 hover:border-dark-700/50 hover:bg-dark-800/30', + ? 'border-accent-500/50 bg-accent-500/5' + : 'border-dark-800/50 bg-dark-900/50 hover:border-dark-700/50', )} > - {/* Header */} -
-
-

{tariff.name}

- {tariff.description && ( -

{tariff.description}

- )} -
-
+ +
+ + {/* Info */} +
+

{tariff.name}

+

- {isSelected && ( - - - - )} -

+ {tariff.traffic_limit_gb > 0 + ? `${tariff.traffic_limit_gb} ${t('gift.gbShort')}` + : t('gift.unlimitedTraffic')} + {' \u2022 '} + {t('gift.deviceCount', { count: tariff.device_limit })} +

- {/* Info row */} -
- - - - - {tariff.traffic_limit_gb === 0 ? '\u221E' : tariff.traffic_limit_gb}{' '} - {t('landing.gb', 'GB')} - - - - - - {tariff.device_limit} {t('landing.devices', 'devices')} - + {/* Checkmark circle */} +
+ {isSelected && }
+ + ); +} - {/* Price */} - {selectedPeriod && ( -
-
- - {formatPrice(selectedPeriod.price_kopeks)} - - {selectedPeriod.original_price_kopeks != null && - selectedPeriod.original_price_kopeks > selectedPeriod.price_kopeks && ( - <> - - {formatPrice(selectedPeriod.original_price_kopeks)} - - {selectedPeriod.discount_percent != null && ( - - -{selectedPeriod.discount_percent}% - - )} - - )} -
-
+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 ( + ); } @@ -291,7 +405,7 @@ function PaymentModeToggle({ return (
); @@ -345,7 +459,7 @@ function PaymentMethodCard({ 'rounded-2xl border transition-all duration-200', isSelected ? 'border-accent-500/50 bg-accent-500/5' - : 'border-dark-800/50 bg-dark-900/50 hover:border-dark-700/50 hover:bg-dark-800/30', + : 'border-dark-800/50 bg-dark-900/50 hover:border-dark-700/50', )} > - {/* Sub-options */} {isSelected && hasSubOptions && (
@@ -394,7 +502,7 @@ function PaymentMethodCard({ 'rounded-xl px-4 py-2 text-sm font-medium transition-all duration-200', selectedSubOption === opt.id ? 'bg-accent-500 text-white shadow-sm shadow-accent-500/25' - : 'bg-dark-800/50 text-dark-300 hover:bg-dark-700/50 hover:text-dark-100', + : 'bg-dark-800/50 text-dark-300 hover:bg-dark-700/50', )} > {opt.name} @@ -407,246 +515,19 @@ function PaymentMethodCard({ ); } -function RecipientSection({ value, onChange }: { value: string; onChange: (v: string) => void }) { - const { t } = useTranslation(); - - return ( -
-
- - onChange(e.target.value)} - placeholder={t('gift.recipientPlaceholder', 'email@example.com or @telegram')} - 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 transition-colors focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/25" - /> -

- {t( - 'gift.recipientHint', - 'Enter the email or Telegram username of the person you want to gift', - )} -

-
-
- ); -} - -function GiftMessageSection({ value, onChange }: { value: string; onChange: (v: string) => void }) { - const { t } = useTranslation(); - - return ( - - -
- -