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/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/locales/en.json b/src/locales/en.json index 67f6f2b..4724e0b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -4215,6 +4215,54 @@ }, "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", + "copyMessage": "Copy", + "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/ru.json b/src/locales/ru.json index 68eded5..7f5b117 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -4777,6 +4777,59 @@ }, "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": "Сообщение для отправки", + "copyMessage": "Копировать", + "activatedBy": "Активирован пользователем {{username}}", + "sentTo": "Отправлен {{recipient}}", + "daysShort": "дн.", + "devicesShort": "устр.", + "devicesShort_one": "устр.", + "devicesShort_few": "устр.", + "devicesShort_many": "устр.", + "gbShort": "ГБ", + "unlimitedTraffic": "Безлимит", + "shareModalActivateVia": "Активировать через бота:", + "shareModalActivateViaCabinet": "Или через сайт:", + "copyMessage": "Скопировать сообщение", + "shareToastCopied": "Сообщение скопировано" } } 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 ( - - -
- -