diff --git a/src/api/gift.ts b/src/api/gift.ts index c96c180..a009b5a 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; @@ -86,6 +86,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 +137,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/locales/en.json b/src/locales/en.json index c3175bc..6d9951d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -4201,6 +4201,41 @@ }, "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", + "myGiftsEmpty": "No gifts yet", + "myGiftsEmptyDesc": "Buy a gift for a friend or activate a received code", + "sentGiftsTitle": "Sent", + "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:", + "activatedBy": "Activated by {{username}}", + "sentTo": "Sent to {{recipient}}", + "daysShort": "days", + "devicesShort": "dev." } } diff --git a/src/locales/ru.json b/src/locales/ru.json index 88b897b..fb98fb2 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -4763,6 +4763,41 @@ }, "warning": { "telegram_unresolvable": "Не удалось проверить этот Telegram-юзернейм. Получатель может не получить уведомление о подарке." - } + }, + "pageTitle": "Подарки", + "tabBuy": "Купить", + "tabActivate": "Активировать", + "tabMyGifts": "Мои подарки", + "selectTariff": "ВЫБЕРИТЕ ТАРИФ", + "selectPeriod": "ПЕРИОД ПОДПИСКИ", + "deviceCount": "{{count}} устройство", + "deviceCount_other": "{{count}} устройств", + "activateTitle": "Введите код подарка", + "activateDescription": "Введите код подарка, полученный от друга или знакомого", + "activateCodePlaceholder": "GIFT-XXXXXXXXXXXX", + "activateButton": "Активировать подарок", + "activating": "Активация...", + "activateSuccess": "Подарок активирован!", + "activateSuccessDesc": "{{tariff}} — {{days}} дн. добавлено к вашей подписке", + "activateError": "Не удалось активировать подарок", + "myGiftsEmpty": "У вас пока нет подарков", + "myGiftsEmptyDesc": "Купите подарок для друга или активируйте полученный код", + "sentGiftsTitle": "Отправленные", + "receivedGiftsTitle": "Полученные", + "statusAvailable": "ДОСТУПЕН", + "statusActivated": "АКТИВИРОВАН", + "statusPending": "ОЖИДАЕТ", + "statusDelivered": "ДОСТАВЛЕН", + "statusFailed": "ОШИБКА", + "statusExpired": "ПРОСРОЧЕН", + "statusPendingActivation": "ОЖИДАЕТ АКТИВАЦИИ", + "copyCode": "КОПИРОВАТЬ", + "codeCopied": "Код скопирован!", + "shareGift": "ПОДЕЛИТЬСЯ", + "shareText": "У меня есть подарок для тебя! Активируй его здесь:", + "activatedBy": "Активирован пользователем {{username}}", + "sentTo": "Отправлен {{recipient}}", + "daysShort": "дн.", + "devicesShort": "устр." } } diff --git a/src/pages/GiftSubscription.tsx b/src/pages/GiftSubscription.tsx index 649e653..cfd9c4e 100644 --- a/src/pages/GiftSubscription.tsx +++ b/src/pages/GiftSubscription.tsx @@ -10,6 +10,8 @@ import type { GiftTariffPeriod, GiftPaymentMethod, GiftPurchaseRequest, + SentGift, + ReceivedGift, } from '../api/gift'; import { cn } from '../lib/utils'; @@ -17,22 +19,136 @@ import { getApiErrorMessage } from '../utils/api-error'; import { formatPrice } from '../utils/format'; // ============================================================ -// Helpers +// SVG Icons // ============================================================ -function detectContactType(value: string): 'email' | 'telegram' { - return value.startsWith('@') ? 'telegram' : 'email'; +function GiftIcon({ className }: { className?: string }) { + return ( + + + + + + + + ); } -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 CopyIcon({ 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 +165,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 +236,7 @@ function ErrorState({ message }: { message: string }) { /> -

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

+

{t('gift.failedTitle')}

{message}

@@ -118,56 +270,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 +299,105 @@ 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 && ( - - - - )} -

+ {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 +416,7 @@ function PaymentModeToggle({ return (
); @@ -345,7 +470,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 +513,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 +526,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 ( - - -
- -