diff --git a/src/App.tsx b/src/App.tsx index 6df73fb..f0c973f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -425,21 +425,25 @@ function App() { - - - - + + + + + + + } /> - - - - + + + + + + + } /> (`/cabinet/gift/purchase/${token}`); return data; }, + + getPendingGifts: async (): Promise => { + const { data } = await apiClient.get('/cabinet/gift/pending'); + return data; + }, }; diff --git a/src/components/dashboard/PendingGiftCard.tsx b/src/components/dashboard/PendingGiftCard.tsx new file mode 100644 index 0000000..85240ad --- /dev/null +++ b/src/components/dashboard/PendingGiftCard.tsx @@ -0,0 +1,80 @@ +import { Link } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { motion } from 'framer-motion'; +import type { PendingGift } from '../../api/gift'; + +interface PendingGiftCardProps { + gifts: PendingGift[]; + className?: string; +} + +export default function PendingGiftCard({ gifts, className }: PendingGiftCardProps) { + const { t } = useTranslation(); + + if (gifts.length === 0) return null; + + return ( +
+ {gifts.map((gift) => ( + + {/* Subtle glow effect */} +
+ +
+ {/* Gift icon */} +
+ + + +
+ + {/* Content */} +
+

{t('gift.pending.title')}

+

+ {gift.tariff_name && ( + + {gift.tariff_name} — {gift.period_days} {t('gift.days')} + + )} + {gift.sender_display && ( + + {t('gift.pending.from', { sender: gift.sender_display })} + + )} +

+ {gift.gift_message && ( +

+ “{gift.gift_message}” +

+ )} +
+ + {/* Activate button */} + + {t('gift.pending.activate')} + +
+ + ))} +
+ ); +} diff --git a/src/locales/en.json b/src/locales/en.json index c1554a6..ad055ea 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -4152,13 +4152,26 @@ "tryAgain": "Try again", "pollTimeout": "Processing is taking longer than usual", "pollTimeoutDesc": "Try checking the status later", + "pollErrorTitle": "Could not check gift status", + "pollErrorDesc": "Your purchase was successful. Check your dashboard for details.", "retry": "Check again", + "notFound": "Gift configuration not found", "noToken": "Invalid link", + "noTokenDesc": "This gift link is invalid or has expired.", + "days": "days", "tariff": "Tariff", "period": "Period", "giftMessageLabel": "Message", "recipientLabel": "Recipient", "featureDisabled": "Gift feature is temporarily unavailable", - "redirecting": "Redirecting..." + "redirecting": "Redirecting...", + "pending": { + "title": "You received a gift!", + "from": "from {{sender}}", + "activate": "Activate" + }, + "warning": { + "telegram_unresolvable": "Could not verify this Telegram username. The recipient may not receive a notification about the gift." + } } } diff --git a/src/locales/fa.json b/src/locales/fa.json index de87e73..f94bde1 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -3647,13 +3647,26 @@ "tryAgain": "تلاش مجدد", "pollTimeout": "پردازش بیش از حد معمول طول کشیده", "pollTimeoutDesc": "بعداً وضعیت را بررسی کنید", + "pollErrorTitle": "بررسی وضعیت هدیه امکان‌پذیر نیست", + "pollErrorDesc": "خرید شما موفقیت‌آمیز بود. وضعیت را در داشبورد بررسی کنید.", "retry": "بررسی مجدد", + "notFound": "تنظیمات هدیه یافت نشد", "noToken": "لینک نامعتبر", + "noTokenDesc": "این لینک هدیه نامعتبر است یا منقضی شده.", + "days": "روز", "tariff": "طرح", "period": "مدت", "giftMessageLabel": "پیام", "recipientLabel": "گیرنده", "featureDisabled": "قابلیت هدیه موقتاً در دسترس نیست", - "redirecting": "در حال انتقال..." + "redirecting": "در حال انتقال...", + "pending": { + "title": "شما یک هدیه دریافت کردید!", + "from": "از {{sender}}", + "activate": "فعال‌سازی" + }, + "warning": { + "telegram_unresolvable": "نام کاربری تلگرام قابل تأیید نبود. ممکن است گیرنده اعلان هدیه را دریافت نکند." + } } } diff --git a/src/locales/ru.json b/src/locales/ru.json index a755e3d..6abfecc 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -4714,13 +4714,26 @@ "tryAgain": "Попробовать снова", "pollTimeout": "Обработка занимает больше времени, чем обычно", "pollTimeoutDesc": "Попробуйте проверить статус позже", + "pollErrorTitle": "Не удалось проверить статус подарка", + "pollErrorDesc": "Ваша покупка прошла успешно. Проверьте статус на главной странице.", "retry": "Проверить снова", + "notFound": "Конфигурация подарков не найдена", "noToken": "Ссылка недействительна", + "noTokenDesc": "Ссылка на подарок недействительна или просрочена.", + "days": "дн.", "tariff": "Тариф", "period": "Период", "giftMessageLabel": "Сообщение", "recipientLabel": "Получатель", "featureDisabled": "Функция подарков временно недоступна", - "redirecting": "Перенаправляем..." + "redirecting": "Перенаправляем...", + "pending": { + "title": "Вам подарили подписку!", + "from": "от {{sender}}", + "activate": "Активировать" + }, + "warning": { + "telegram_unresolvable": "Не удалось проверить этот Telegram-юзернейм. Получатель может не получить уведомление о подарке." + } } } diff --git a/src/locales/zh.json b/src/locales/zh.json index a53b03c..c7eb3b1 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -3646,13 +3646,26 @@ "tryAgain": "重试", "pollTimeout": "处理时间超出预期", "pollTimeoutDesc": "请稍后再查看状态", + "pollErrorTitle": "无法检查礼物状态", + "pollErrorDesc": "您的购买已成功。请在仪表板上查看详情。", "retry": "再次检查", + "notFound": "未找到礼物配置", "noToken": "无效链接", + "noTokenDesc": "此礼物链接无效或已过期。", + "days": "天", "tariff": "套餐", "period": "时长", "giftMessageLabel": "消息", "recipientLabel": "收件人", "featureDisabled": "礼物功能暂时不可用", - "redirecting": "正在跳转..." + "redirecting": "正在跳转...", + "pending": { + "title": "您收到了一份礼物!", + "from": "来自 {{sender}}", + "activate": "激活" + }, + "warning": { + "telegram_unresolvable": "无法验证此 Telegram 用户名。收件人可能不会收到礼物通知。" + } } } diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 0cc715c..7564778 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -14,6 +14,8 @@ import SubscriptionCardActive from '../components/dashboard/SubscriptionCardActi import SubscriptionCardExpired from '../components/dashboard/SubscriptionCardExpired'; import TrialOfferCard from '../components/dashboard/TrialOfferCard'; import StatsGrid from '../components/dashboard/StatsGrid'; +import { giftApi } from '../api/gift'; +import PendingGiftCard from '../components/dashboard/PendingGiftCard'; import { API } from '../config/constants'; const ChevronRightIcon = () => ( @@ -80,6 +82,13 @@ export default function Dashboard() { retry: false, }); + const { data: pendingGifts } = useQuery({ + queryKey: ['pending-gifts'], + queryFn: giftApi.getPendingGifts, + staleTime: 30_000, + retry: false, + }); + const activateTrialMutation = useMutation({ mutationFn: subscriptionApi.activateTrial, onSuccess: () => { @@ -221,6 +230,9 @@ export default function Dashboard() {

{t('dashboard.yourSubscription')}

+ {/* Pending Gift Activations */} + {pendingGifts && pendingGifts.length > 0 && } + {/* Subscription Status Card */} {subLoading ? (
diff --git a/src/pages/GiftResult.tsx b/src/pages/GiftResult.tsx index a1361b8..7083d60 100644 --- a/src/pages/GiftResult.tsx +++ b/src/pages/GiftResult.tsx @@ -10,6 +10,8 @@ import { AnimatedCrossmark } from '@/components/ui/AnimatedCrossmark'; const MAX_POLL_MS = 10 * 60 * 1000; // 10 minutes +const KNOWN_WARNINGS = new Set(['telegram_unresolvable']); + // ============================================================ // Sub-components // ============================================================ @@ -29,7 +31,7 @@ function PendingState() { {t('gift.processing', 'Processing your gift...')}

- {t('gift.processingDesc', 'Please wait while we process your payment')} + {t('gift.pendingDesc', 'Please wait while we process your payment')}

@@ -41,11 +43,13 @@ function DeliveredState({ tariffName, periodDays, giftMessage, + warning, }: { recipientContact: string | null; tariffName: string | null; periodDays: number | null; giftMessage: string | null; + warning: string | null; }) { const { t } = useTranslation(); const navigate = useNavigate(); @@ -59,7 +63,7 @@ function DeliveredState({
-

{t('gift.sent', 'Gift sent!')}

+

{t('gift.successTitle', 'Gift sent!')}

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

{tariffName} — {periodDays} {t('gift.days', 'days')} @@ -67,7 +71,7 @@ function DeliveredState({ )} {recipientContact && (

- {t('gift.sentTo', { + {t('gift.successDesc', { contact: recipientContact, defaultValue: `Sent to ${recipientContact}`, })} @@ -78,6 +82,12 @@ function DeliveredState({ )}

+ {warning && ( +
+

{t(`gift.warning.${warning}`)}

+
+ )} + + + ); +} + function PollTimedOutState({ onRetry }: { onRetry: () => void }) { const { t } = useTranslation(); @@ -218,11 +285,11 @@ function PollTimedOutState({ onRetry }: { onRetry: () => void }) {

- {t('gift.pollTimedOut', 'Taking longer than expected')} + {t('gift.pollTimeout', 'Taking longer than expected')}

{t( - 'gift.pollTimedOutDesc', + 'gift.pollTimeoutDesc', 'Payment processing is taking longer than usual. You can try checking again.', )}

@@ -232,7 +299,7 @@ function PollTimedOutState({ onRetry }: { onRetry: () => void }) { onClick={onRetry} className="rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400" > - {t('common.retry', 'Retry')} + {t('gift.retry', 'Retry')} ); @@ -264,9 +331,9 @@ function NoTokenState() {
-

{t('gift.invalidLink', 'Invalid link')}

+

{t('gift.noToken', 'Invalid link')}

- {t('gift.invalidLinkDesc', 'This gift link is invalid or has expired.')} + {t('gift.noTokenDesc', 'This gift link is invalid or has expired.')}

); @@ -288,6 +355,8 @@ export default function GiftResult() { const [searchParams] = useSearchParams(); const token = searchParams.get('token'); const mode = searchParams.get('mode'); + const rawWarning = searchParams.get('warning'); + const warning = rawWarning && KNOWN_WARNINGS.has(rawWarning) ? rawWarning : null; const pollStart = useRef(Date.now()); const [pollTimedOut, setPollTimedOut] = useState(false); @@ -307,7 +376,8 @@ export default function GiftResult() { if (isBalanceMode) return false; const s = query.state.data?.status; - if (s === 'delivered' || s === 'failed' || s === 'pending_activation') return false; + if (s === 'delivered' || s === 'failed' || s === 'pending_activation' || s === 'expired') + return false; // Check poll timeout if (Date.now() - pollStart.current > MAX_POLL_MS) { @@ -343,7 +413,7 @@ export default function GiftResult() { const isDelivered = status?.status === 'delivered'; const isPendingActivation = status?.status === 'pending_activation'; - const isFailed = status?.status === 'failed'; + const isFailed = status?.status === 'failed' || status?.status === 'expired'; return (
@@ -352,7 +422,9 @@ export default function GiftResult() { aria-live="polite" aria-atomic="true" > - {isError ? ( + {isError && isBalanceMode ? ( + + ) : isError ? ( ) : isDelivered ? ( ) : isPendingActivation ? ( ) : isFailed ? ( diff --git a/src/pages/GiftSubscription.tsx b/src/pages/GiftSubscription.tsx index da459c0..b5d0336 100644 --- a/src/pages/GiftSubscription.tsx +++ b/src/pages/GiftSubscription.tsx @@ -27,8 +27,10 @@ function detectContactType(value: string): 'email' | 'telegram' { function isValidContact(value: string): boolean { const trimmed = value.trim(); if (!trimmed) return false; - if (trimmed.startsWith('@')) return trimmed.length >= 4; - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed); + 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 formatPeriodLabel( @@ -82,7 +84,7 @@ function ErrorState({ message }: { message: string }) { />
-

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

+

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

{message}

@@ -117,11 +119,9 @@ function DisabledState() {

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

-

- {t('gift.disabledRedirect', 'Redirecting to dashboard...')} -

+

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

); @@ -512,7 +512,7 @@ function GiftSummaryCard({ {selectedTariff && (

- {t('gift.selectedTariff', 'Tariff')} + {t('gift.tariff', 'Tariff')}

{selectedTariff.name}

@@ -577,7 +577,7 @@ function GiftSummaryCard({ to="/balance" className="font-medium text-accent-400 underline underline-offset-2" > - {t('gift.topUp', 'Top up')} + {t('gift.topUpBalance', 'Top up balance')}

@@ -650,7 +650,6 @@ export default function GiftSubscription() { const [paymentMode, setPaymentMode] = useState<'balance' | 'gateway'>('balance'); const [selectedMethod, setSelectedMethod] = useState(null); const [selectedSubOption, setSelectedSubOption] = useState(null); - const [isSubmitting, setIsSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); // Collect ALL unique periods across ALL tariffs @@ -749,24 +748,27 @@ export default function GiftSubscription() { // Balance mode - show success queryClient.invalidateQueries({ queryKey: ['balance'] }); queryClient.invalidateQueries({ queryKey: ['gift-config'] }); - navigate('/gift/result?token=' + result.purchase_token + '&mode=balance'); + const params = new URLSearchParams({ token: result.purchase_token, mode: 'balance' }); + if (result.warning) { + params.set('warning', result.warning); + } + navigate('/gift/result?' + params.toString()); } }, onError: (err) => { const msg = getApiErrorMessage( err, - t('gift.purchaseError', 'Something went wrong. Please try again.'), + t('gift.failedDesc', 'Something went wrong. Please try again.'), ); setSubmitError(msg); - setIsSubmitting(false); }, }); // Submit handler const handleSubmit = () => { - if (!canSubmit || isSubmitting) return; + if (!selectedTariffId || !selectedPeriodDays || !canSubmit || purchaseMutation.isPending) + return; - setIsSubmitting(true); setSubmitError(null); let paymentMethod: string | undefined; @@ -778,8 +780,8 @@ export default function GiftSubscription() { } const data: GiftPurchaseRequest = { - tariff_id: selectedTariffId!, - period_days: selectedPeriodDays!, + tariff_id: selectedTariffId, + period_days: selectedPeriodDays, recipient_type: detectContactType(recipientValue), recipient_value: recipientValue.trim(), gift_message: giftMessage.trim() || undefined, @@ -885,7 +887,7 @@ export default function GiftSubscription() { {/* Recipient */}

- {t('gift.recipientSection', 'Recipient')} + {t('gift.recipientLabel', 'Recipient')}

- {t('gift.messageSection', 'Personal message')} + {t('gift.giftMessageLabel', 'Message')}

@@ -907,7 +909,7 @@ export default function GiftSubscription() { {/* Payment mode toggle */}

- {t('gift.paymentModeSection', 'Payment method')} + {t('gift.paymentMode', 'Payment method')}