import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; import { promoApi, PromoOffer } from '../api/promo'; import { ClockIcon, CheckIcon, XCircleIcon } from './icons'; import { useDestructiveConfirm } from '@/platform/hooks/useNativeDialog'; // Helper functions const formatTimeLeft = ( expiresAt: string, t: (key: string, options?: Record) => string, ): string => { const now = new Date(); // Ensure UTC parsing - if no timezone specified, assume UTC let expires: Date; if (expiresAt.includes('Z') || expiresAt.includes('+') || expiresAt.includes('-', 10)) { expires = new Date(expiresAt); } else { // No timezone - treat as UTC expires = new Date(expiresAt + 'Z'); } const diffMs = expires.getTime() - now.getTime(); if (diffMs <= 0) return t('promo.offers.expired'); const hours = Math.floor(diffMs / (1000 * 60 * 60)); const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); if (hours > 24) { const days = Math.floor(hours / 24); return `${days} ${t('promo.time.days')}`; } if (hours > 0) { return `${hours}${t('promo.time.hours')} ${minutes}${t('promo.time.minutes')}`; } return `${minutes}${t('promo.time.minutes')}`; }; const getOfferIcon = (effectType: string, discountPercent?: number | null) => { if (effectType === 'test_access') return 🚀; if (discountPercent) return 🏷️; return 🎁; }; const getOfferTitle = ( offer: PromoOffer, t: (key: string, options?: Record) => string, ): string => { if (offer.effect_type === 'test_access') { return t('promo.offers.testAccess'); } if (offer.discount_percent) { return t('promo.offers.discountPercent', { percent: offer.discount_percent }); } return t('promo.offers.specialOffer'); }; const getOfferDescription = ( offer: PromoOffer, t: (key: string, options?: Record) => string, ): string => { if (offer.effect_type === 'test_access') { const squads = offer.extra_data?.test_squad_uuids?.length || 0; return squads > 0 ? t('promo.offers.serverAccess', { count: squads }) : t('promo.offers.additionalServers'); } return t('promo.offers.activateDiscountHint'); }; interface PromoOffersSectionProps { className?: string; } export default function PromoOffersSection({ className = '' }: PromoOffersSectionProps) { const { t } = useTranslation(); const navigate = useNavigate(); const queryClient = useQueryClient(); const confirmDeactivate = useDestructiveConfirm(); const [claimingId, setClaimingId] = useState(null); const [successMessage, setSuccessMessage] = useState(null); const [errorMessage, setErrorMessage] = useState(null); // Fetch available offers const { data: offers = [], isLoading: offersLoading } = useQuery({ queryKey: ['promo-offers'], queryFn: promoApi.getOffers, staleTime: 30000, }); // Fetch active discount const { data: activeDiscount } = useQuery({ queryKey: ['active-discount'], queryFn: promoApi.getActiveDiscount, staleTime: 30000, }); // Claim offer mutation const claimMutation = useMutation({ mutationFn: promoApi.claimOffer, onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['promo-offers'] }); queryClient.invalidateQueries({ queryKey: ['active-discount'] }); queryClient.invalidateQueries({ queryKey: ['subscription'] }); queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] }); queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); queryClient.invalidateQueries({ queryKey: ['balance'] }); setSuccessMessage(result.message); setClaimingId(null); setTimeout(() => setSuccessMessage(null), 5000); }, onError: (error: unknown) => { const axiosErr = error as { response?: { data?: { detail?: string } } }; setErrorMessage(axiosErr.response?.data?.detail || t('promo.offers.activationFailed')); setClaimingId(null); setTimeout(() => setErrorMessage(null), 5000); }, }); // Deactivate discount mutation. // A discount granted by a PROMOCODE must be turned off via the dedicated route // (POST /cabinet/promocode/deactivate-discount), which also rolls back the promocode // usage (deletes the PromoCodeUse, decrements current_uses, strips the promo group) so // the user can re-activate it later. The plain clearActiveDiscount route only zeroes // the discount fields and is correct for admin/offer-sourced discounts. const deactivateMutation = useMutation({ mutationFn: async () => { const source = activeDiscount?.source ?? ''; if (source.startsWith('promocode:')) { await promoApi.deactivateDiscount(); } else { await promoApi.clearActiveDiscount(); } }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['active-discount'] }); queryClient.invalidateQueries({ queryKey: ['promo-offers'] }); queryClient.invalidateQueries({ queryKey: ['subscription'] }); queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); setSuccessMessage(t('promo.deactivate.success')); setTimeout(() => setSuccessMessage(null), 5000); }, onError: (error: unknown) => { // detail may be a plain string (clearActiveDiscount route) or a structured // { code, message } object (deactivate-discount route). Handle both. const axiosErr = error as { response?: { data?: { detail?: string | { message?: string } } }; }; const detail = axiosErr.response?.data?.detail; const message = typeof detail === 'string' ? detail : detail?.message; setErrorMessage(message || t('promo.deactivate.error')); setTimeout(() => setErrorMessage(null), 5000); }, }); const handleClaim = (offerId: number) => { setClaimingId(offerId); setErrorMessage(null); setSuccessMessage(null); claimMutation.mutate(offerId); }; const handleUseNow = () => { navigate('/subscription/purchase'); }; const handleDeactivateClick = async () => { setErrorMessage(null); setSuccessMessage(null); const confirmed = await confirmDeactivate( t('promo.deactivate.confirmDescription', { percent: activeDiscount?.discount_percent || 0, }), t('promo.deactivate.confirm'), t('promo.deactivate.confirmTitle'), ); if (confirmed) { deactivateMutation.mutate(); } }; // Filter unclaimed and active offers const availableOffers = offers.filter((o) => o.is_active && !o.is_claimed); // Don't render if no offers and no active discount if ( !offersLoading && availableOffers.length === 0 && (!activeDiscount || !activeDiscount.is_active) ) { return null; } return (
{/* Active Discount Banner with actions */} {activeDiscount && activeDiscount.is_active && activeDiscount.discount_percent > 0 && (
{/* Header */}
🏷️

{t('promo.offers.discountActiveTitle', { percent: activeDiscount.discount_percent, })}

-{activeDiscount.discount_percent}%
{activeDiscount.expires_at && (
{t('promo.offers.expires', { time: formatTimeLeft(activeDiscount.expires_at, t), })}
)}
{/* Action Buttons */}
)} {/* Success/Error Messages */} {successMessage && (
{successMessage}
)} {errorMessage && (
{errorMessage}
)} {/* Available Offers */} {availableOffers.length > 0 && (
{availableOffers.map((offer) => (
{getOfferIcon(offer.effect_type, offer.discount_percent)}

{getOfferTitle(offer, t)}

{offer.effect_type === 'test_access' && ( {t('promo.offers.test')} )}

{getOfferDescription(offer, t)}

{t('promo.offers.remaining', { time: formatTimeLeft(offer.expires_at, t) })}
))}
)} {/* Loading State */} {offersLoading && (
)}
); }