diff --git a/src/pages/GiftResult.tsx b/src/pages/GiftResult.tsx new file mode 100644 index 0000000..a1361b8 --- /dev/null +++ b/src/pages/GiftResult.tsx @@ -0,0 +1,380 @@ +import { useCallback, useRef, useState } from 'react'; +import { useSearchParams, useNavigate } from 'react-router'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { motion } from 'framer-motion'; +import { giftApi } from '../api/gift'; +import { Spinner } from '@/components/ui/Spinner'; +import { AnimatedCheckmark } from '@/components/ui/AnimatedCheckmark'; +import { AnimatedCrossmark } from '@/components/ui/AnimatedCrossmark'; + +const MAX_POLL_MS = 10 * 60 * 1000; // 10 minutes + +// ============================================================ +// Sub-components +// ============================================================ + +function PendingState() { + const { t } = useTranslation(); + + return ( + + +
+

+ {t('gift.processing', 'Processing your gift...')} +

+

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

+
+
+ ); +} + +function DeliveredState({ + recipientContact, + tariffName, + periodDays, + giftMessage, +}: { + recipientContact: string | null; + tariffName: string | null; + periodDays: number | null; + giftMessage: string | null; +}) { + const { t } = useTranslation(); + const navigate = useNavigate(); + + return ( + + + +
+

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

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

+ {tariffName} — {periodDays} {t('gift.days', 'days')} +

+ )} + {recipientContact && ( +

+ {t('gift.sentTo', { + contact: recipientContact, + defaultValue: `Sent to ${recipientContact}`, + })} +

+ )} + {giftMessage && ( +

“{giftMessage}”

+ )} +
+ + +
+ ); +} + +function PendingActivationState({ + recipientContact, + tariffName, + periodDays, +}: { + recipientContact: string | null; + tariffName: string | null; + periodDays: number | null; +}) { + const { t } = useTranslation(); + const navigate = useNavigate(); + + return ( + + {/* Info icon */} +
+ + + +
+ +
+

+ {t('gift.pendingActivation', 'Gift pending activation')} +

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

+ {tariffName} — {periodDays} {t('gift.days', 'days')} +

+ )} + {recipientContact && ( +

+ {t('gift.sentTo', { + contact: recipientContact, + defaultValue: `Sent to ${recipientContact}`, + })} +

+ )} +

+ {t( + 'gift.pendingActivationDesc', + 'The recipient currently has an active subscription. Your gift will be activated once their current subscription expires.', + )} +

+
+ + +
+ ); +} + +function FailedState() { + const { t } = useTranslation(); + const navigate = useNavigate(); + + return ( + + + +
+

+ {t('gift.failed', 'Something went wrong')} +

+

+ {t('gift.failedDesc', 'Your gift could not be processed. Please try again.')} +

+
+ + +
+ ); +} + +function PollTimedOutState({ onRetry }: { onRetry: () => void }) { + const { t } = useTranslation(); + + return ( + +
+ + + +
+
+

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

+

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

+
+ +
+ ); +} + +function NoTokenState() { + const { t } = useTranslation(); + const navigate = useNavigate(); + + return ( + +
+ + + +
+
+

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

+

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

+
+ +
+ ); +} + +// ============================================================ +// Main Component +// ============================================================ + +export default function GiftResult() { + const [searchParams] = useSearchParams(); + const token = searchParams.get('token'); + const mode = searchParams.get('mode'); + + const pollStart = useRef(Date.now()); + const [pollTimedOut, setPollTimedOut] = useState(false); + + const isBalanceMode = mode === 'balance'; + + const { + data: status, + isError, + refetch, + } = useQuery({ + queryKey: ['gift-status', token], + queryFn: () => giftApi.getPurchaseStatus(token!), + enabled: !!token && !pollTimedOut, + refetchInterval: (query) => { + // Balance mode: fetch once, no polling + if (isBalanceMode) return false; + + const s = query.state.data?.status; + if (s === 'delivered' || s === 'failed' || s === 'pending_activation') return false; + + // Check poll timeout + if (Date.now() - pollStart.current > MAX_POLL_MS) { + setPollTimedOut(true); + return false; + } + + return 3000; + }, + retry: 2, + }); + + const handleRetryPoll = useCallback(() => { + pollStart.current = Date.now(); + setPollTimedOut(false); + refetch(); + }, [refetch]); + + // No token + if (!token) { + return ( +
+
+ +
+
+ ); + } + + const isDelivered = status?.status === 'delivered'; + const isPendingActivation = status?.status === 'pending_activation'; + const isFailed = status?.status === 'failed'; + + return ( +
+
+ {isError ? ( + + ) : isDelivered ? ( + + ) : isPendingActivation ? ( + + ) : isFailed ? ( + + ) : pollTimedOut ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/src/pages/GiftSubscription.tsx b/src/pages/GiftSubscription.tsx new file mode 100644 index 0000000..da459c0 --- /dev/null +++ b/src/pages/GiftSubscription.tsx @@ -0,0 +1,983 @@ +import { useState, useMemo, useEffect } from 'react'; +import { useNavigate, 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, +} from '../api/gift'; + +import { cn } from '../lib/utils'; +import { getApiErrorMessage } from '../utils/api-error'; +import { formatPrice } from '../utils/format'; + +// ============================================================ +// Helpers +// ============================================================ + +function detectContactType(value: string): 'email' | 'telegram' { + return value.startsWith('@') ? 'telegram' : 'email'; +} + +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); +} + +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 }); +} + +// ============================================================ +// Sub-components +// ============================================================ + +function LoadingSkeleton() { + return ( +
+
+
+
+
+ ); +} + +function ErrorState({ message }: { message: string }) { + const { t } = useTranslation(); + + return ( +
+
+
+ + + +
+

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

+

{message}

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

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

+

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

+
+
+ ); +} + +function PeriodTabs({ + periods, + selectedDays, + onSelect, +}: { + periods: GiftTariffPeriod[]; + selectedDays: number; + onSelect: (days: number) => void; +}) { + const { t } = useTranslation(); + + return ( +
+ {periods.map((period) => ( + + ))} +
+ ); +} + +function TariffCard({ + tariff, + isSelected, + selectedPeriod, + onSelect, +}: { + tariff: GiftTariff; + isSelected: boolean; + selectedPeriod: GiftTariffPeriod | undefined; + onSelect: () => void; +}) { + const { t } = useTranslation(); + + 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 ( +
+ + + {/* Sub-options */} + {isSelected && hasSubOptions && ( +
+
+ {method.sub_options!.map((opt) => ( + + ))} +
+
+ )} +
+ ); +} + +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 ( + + +
+ +