From 6c8d2ad69e0fad922157be493e5b5558c8bb45dd Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 4 Feb 2026 01:17:05 +0300 Subject: [PATCH 1/4] Update Subscription.tsx --- src/pages/Subscription.tsx | 89 ++++++++++++++++++++++++++------------ 1 file changed, 62 insertions(+), 27 deletions(-) diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 00d39a7..0f3ca0d 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -3139,7 +3139,7 @@ export default function Subscription() { > {displayDiscount && displayDiscount > 0 && (
@@ -3147,7 +3147,7 @@ export default function Subscription() {
)}
{period.label}
-
+
{formatPrice(promoPeriod.price)} @@ -3186,20 +3186,41 @@ export default function Subscription() { : '' } ${!option.is_available ? 'cursor-not-allowed opacity-50' : ''}`} > - {promoTraffic.percent && ( -
- -{promoTraffic.percent}% -
- )} -
{option.label}
-
- {formatPrice(promoTraffic.price)} - {promoTraffic.original && ( - - {formatPrice(promoTraffic.original)} - - )} -
+ {(() => { + const trafficDisplayDiscount = hasExistingDiscount + ? option.discount_percent + : promoTraffic.percent; + const trafficDisplayOriginal = hasExistingDiscount + ? option.original_price_kopeks + : promoTraffic.original; + return ( + <> + {trafficDisplayDiscount && trafficDisplayDiscount > 0 && ( +
+ -{trafficDisplayDiscount}% +
+ )} +
+ {option.label} +
+
+ + {formatPrice(promoTraffic.price)} + + {trafficDisplayOriginal && + trafficDisplayOriginal > promoTraffic.price && ( + + {formatPrice(trafficDisplayOriginal)} + + )} +
+ + ); + })()} ); })} @@ -3240,11 +3261,20 @@ export default function Subscription() { : 'cursor-not-allowed border-dark-800/30 bg-dark-900/30 opacity-50' }`} > - {promoServer.percent && ( -
- -{promoServer.percent}% -
- )} + {(() => { + const serverDisplayDiscount = hasExistingDiscount + ? server.discount_percent + : promoServer.percent; + return serverDisplayDiscount && serverDisplayDiscount > 0 ? ( +
+ -{serverDisplayDiscount}% +
+ ) : null; + })()}
{server.name}
-
+
{formatPrice(promoServer.price)} {t('subscription.perMonth')} - {promoServer.original && ( - - {formatPrice(promoServer.original)} - - )} + {(() => { + const serverOriginal = hasExistingDiscount + ? server.original_price_kopeks + : promoServer.original; + return serverOriginal && serverOriginal > promoServer.price ? ( + + {formatPrice(serverOriginal)} + + ) : null; + })()}
From 8c9b161b587758a475b348dccb1b727eb7fd2c98 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 4 Feb 2026 02:09:31 +0300 Subject: [PATCH 2/4] Update promo.ts --- src/api/promo.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/api/promo.ts b/src/api/promo.ts index 9e82625..2a2f601 100644 --- a/src/api/promo.ts +++ b/src/api/promo.ts @@ -35,6 +35,10 @@ export interface ClaimOfferResponse { expires_at: string | null; } +export interface DeactivateDiscountResponse { + success: boolean; +} + export interface LoyaltyTierInfo { id: number; name: string; @@ -94,4 +98,10 @@ export const promoApi = { const response = await apiClient.delete<{ message: string }>('/cabinet/promo/active-discount'); return response.data; }, + + // Deactivate current active promo discount + deactivateDiscount: async (): Promise => { + const response = await apiClient.post('/cabinet/promo/deactivate'); + return response.data; + }, }; From 73212e508d5f1d6234edb1a9219cbaf73f0dd6ad Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 4 Feb 2026 02:10:08 +0300 Subject: [PATCH 3/4] Update PromoDiscountBadge.tsx --- src/components/PromoDiscountBadge.tsx | 330 +++++++++++++++++++++----- 1 file changed, 271 insertions(+), 59 deletions(-) diff --git a/src/components/PromoDiscountBadge.tsx b/src/components/PromoDiscountBadge.tsx index 519eca9..16301d0 100644 --- a/src/components/PromoDiscountBadge.tsx +++ b/src/components/PromoDiscountBadge.tsx @@ -1,6 +1,7 @@ -import { useState, useRef, useEffect } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useState, useRef, useEffect, useCallback } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; +import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { promoApi } from '../api/promo'; @@ -30,6 +31,32 @@ const ClockIcon = () => ( ); +const XCircleIcon = () => ( + + + +); + +const WarningIcon = () => ( + + + +); + const formatTimeLeft = (expiresAt: string, t: (key: string) => string): string => { const now = new Date(); // Ensure UTC parsing - if no timezone specified, assume UTC @@ -57,10 +84,133 @@ const formatTimeLeft = (expiresAt: string, t: (key: string) => string): string = return `${minutes}${t('promo.time.minutes')}`; }; +// ------- Confirmation Modal ------- + +interface DeactivateConfirmModalProps { + isOpen: boolean; + discountPercent: number; + isDeactivating: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +function DeactivateConfirmModal({ + isOpen, + discountPercent, + isDeactivating, + onConfirm, + onCancel, +}: DeactivateConfirmModalProps) { + const { t } = useTranslation(); + + // Escape key to close + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !isDeactivating) { + e.preventDefault(); + onCancel(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, isDeactivating, onCancel]); + + // Scroll lock + useEffect(() => { + if (!isOpen) return; + + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = ''; + }; + }, [isOpen]); + + if (!isOpen) return null; + + const modalContent = ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
e.stopPropagation()} + > + {/* Warning header */} +
+
+ +
+

+ {t('promo.deactivate.confirmTitle')} +

+

+ {t('promo.deactivate.confirmDescription', { percent: discountPercent })} +

+
+ + {/* Warning text */} +
+
+

{t('promo.deactivate.warning')}

+
+
+ + {/* Action buttons */} +
+ + +
+
+
+ ); + + if (typeof document !== 'undefined') { + return createPortal(modalContent, document.body); + } + return modalContent; +} + +// ------- Main Component ------- + export default function PromoDiscountBadge() { const { t } = useTranslation(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const [isOpen, setIsOpen] = useState(false); + const [showConfirmModal, setShowConfirmModal] = useState(false); + const [deactivateError, setDeactivateError] = useState(null); const dropdownRef = useRef(null); const { data: activeDiscount } = useQuery({ @@ -70,6 +220,35 @@ export default function PromoDiscountBadge() { refetchInterval: 60000, // Refresh every minute }); + const deactivateMutation = useMutation({ + mutationFn: promoApi.deactivateDiscount, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['active-discount'] }); + queryClient.invalidateQueries({ queryKey: ['promo-offers'] }); + queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); + setShowConfirmModal(false); + setIsOpen(false); + setDeactivateError(null); + }, + onError: (error: unknown) => { + const axiosErr = error as { response?: { data?: { detail?: string } } }; + setDeactivateError(axiosErr.response?.data?.detail || t('promo.deactivate.error')); + }, + }); + + const handleCloseConfirmModal = useCallback(() => { + if (!deactivateMutation.isPending) { + setShowConfirmModal(false); + setDeactivateError(null); + } + }, [deactivateMutation.isPending]); + + const handleConfirmDeactivate = useCallback(() => { + setDeactivateError(null); + deactivateMutation.mutate(); + }, [deactivateMutation]); + // Close dropdown on click outside useEffect(() => { function handleClickOutside(event: MouseEvent) { @@ -97,70 +276,103 @@ export default function PromoDiscountBadge() { navigate('/subscription'); }; + const handleDeactivateClick = () => { + setDeactivateError(null); + setShowConfirmModal(true); + }; + return ( -
- {/* Badge button */} - + <> +
+ {/* Badge button */} + - {/* Dropdown - mobile: fixed centered, desktop: absolute right */} - {isOpen && ( - <> - {/* Mobile backdrop */} -
setIsOpen(false)} - /> + {/* Dropdown - mobile: fixed centered, desktop: absolute right */} + {isOpen && ( + <> + {/* Mobile backdrop */} +
setIsOpen(false)} + /> -
- {/* Header */} -
-
-
- -
-
-
{t('promo.discountActive')}
-
- -{activeDiscount.discount_percent}% +
+ {/* Header */} +
+
+
+ +
+
+
{t('promo.discountActive')}
+
+ -{activeDiscount.discount_percent}% +
+ + {/* Content */} +
+

{t('promo.discountDescription')}

+ + {/* Time remaining */} + {timeLeft && ( +
+ + + {t('promo.expiresIn')}:{' '} + {timeLeft} + +
+ )} + + {/* Deactivation error */} + {deactivateError && ( +
+ {deactivateError} +
+ )} + + {/* CTA Button */} + + + {/* Deactivate Button */} + +
+ + )} +
- {/* Content */} -
-

{t('promo.discountDescription')}

- - {/* Time remaining */} - {timeLeft && ( -
- - - {t('promo.expiresIn')}:{' '} - {timeLeft} - -
- )} - - {/* CTA Button */} - -
-
- - )} -
+ {/* Deactivation Confirmation Modal */} + + ); } From 7f5c0c83b362a4137c76c122911b39b20c3f3503 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 4 Feb 2026 02:10:26 +0300 Subject: [PATCH 4/4] Add files via upload --- src/locales/zh.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/locales/zh.json b/src/locales/zh.json index 6e7eae9..6719583 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1927,6 +1927,15 @@ "test": "测试", "activating": "激活中...", "activate": "激活" + }, + "deactivate": { + "button": "取消优惠码", + "confirmTitle": "取消优惠码?", + "confirmDescription": "您确定要取消 {{percent}}% 的折扣吗?取消后您可以激活其他优惠码。", + "warning": "已取消的优惠码无法重新使用。折扣将立即失效。", + "confirm": "取消折扣", + "deactivating": "取消中...", + "error": "取消优惠码失败,请稍后重试。" } }, "telegramRedirect": {