From 676817caedaecc95b64112c41eae28c2edd434b2 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 3 Feb 2026 23:45:32 +0300 Subject: [PATCH 01/11] Update ConnectionModal.tsx --- src/components/ConnectionModal.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index c024027..1a2aaf5 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -297,13 +297,20 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { const handleConnect = (app: AppInfo) => { if (!app.deepLink || !isValidDeepLink(app.deepLink)) return; - const lang = i18n.language?.startsWith('ru') ? 'ru' : 'en'; - const redirectUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(app.deepLink)}&lang=${lang}`; + const deepLink = app.deepLink; + + // All deep links (including custom schemes like happ://, clash://, etc.) + // go through redirect.html. Telegram's openLink opens the redirect page + // in an external browser, which then performs window.location.href to the + // actual deep link URL. This works for both http(s) and custom schemes. + const redirectUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(deepLink)}&lang=${i18n.language || 'en'}`; + const tg = ( window as unknown as { Telegram?: { WebApp?: { openLink?: (url: string, options?: object) => void } }; } ).Telegram?.WebApp; + if (tg?.openLink) { try { tg.openLink(redirectUrl, { try_instant_view: false, try_browser: true }); From 158a47e1e122dd7d35f611587e09199816a50cff Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 4 Feb 2026 00:50:50 +0300 Subject: [PATCH 02/11] Update Subscription.tsx --- src/pages/Subscription.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index e5b1891..7405d0a 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -2001,10 +2001,10 @@ export default function Subscription() {
- {t('subscription.expired.title')} + {t('subscription.expiredBanner.title')}
- {t('subscription.expired.selectTariff')} + {t('subscription.expiredBanner.selectTariff')}
From 9e590247e058687ee5f86c1b2c556fc94718f193 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 4 Feb 2026 00:51:07 +0300 Subject: [PATCH 03/11] Add files via upload --- src/locales/en.json | 2 +- src/locales/ru.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index e85fa57..970a201 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -320,7 +320,7 @@ "title": "Choose a plan to continue", "description": "Your trial period is ending soon. Select a plan to continue using VPN without restrictions." }, - "expired": { + "expiredBanner": { "title": "Subscription expired", "selectTariff": "Your subscription has expired. Choose a plan below to restore access to VPN." }, diff --git a/src/locales/ru.json b/src/locales/ru.json index 2ca16af..dfb82df 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -342,7 +342,7 @@ "title": "Выберите тариф для продолжения", "description": "Ваш пробный период скоро закончится. Выберите подходящий тариф, чтобы продолжить пользоваться VPN без ограничений." }, - "expired": { + "expiredBanner": { "title": "Подписка истекла", "selectTariff": "Ваша подписка истекла. Выберите тариф ниже, чтобы возобновить доступ к VPN." }, From 9804e3a874057e8bd5a64cce84e77a3739608f15 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 4 Feb 2026 01:00:49 +0300 Subject: [PATCH 04/11] Update Subscription.tsx --- src/pages/Subscription.tsx | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 7405d0a..a846636 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -181,7 +181,10 @@ export default function Subscription() { if (selectedPeriod?.traffic.selectable && (selectedPeriod.traffic.options?.length ?? 0) > 0) { result.push('traffic'); } - if (selectedPeriod && (selectedPeriod.servers.options?.length ?? 0) > 0) { + if ( + selectedPeriod && + (selectedPeriod.servers.options?.filter((s) => s.is_available).length ?? 0) > 0 + ) { result.push('servers'); } if (selectedPeriod && selectedPeriod.devices.max > selectedPeriod.devices.min) { @@ -203,7 +206,12 @@ export default function Subscription() { classicOptions.periods[0]; setSelectedPeriod(defaultPeriod); setSelectedTraffic(classicOptions.selection.traffic_value); - setSelectedServers(classicOptions.selection.servers); + const availableServerUuids = new Set( + defaultPeriod.servers.options?.filter((s) => s.is_available).map((s) => s.uuid) ?? [], + ); + setSelectedServers( + classicOptions.selection.servers.filter((uuid) => availableServerUuids.has(uuid)), + ); setSelectedDevices(classicOptions.selection.devices); } }, [classicOptions, selectedPeriod]); @@ -1636,7 +1644,9 @@ export default function Subscription() { )}
- {countriesData.countries.map((country) => { + {countriesData.countries + .filter((country) => country.is_available || country.is_connected) + .map((country) => { const isCurrentlyConnected = country.is_connected; const isSelected = selectedServersToUpdate.includes(country.uuid); const willBeAdded = !isCurrentlyConnected && isSelected; @@ -3108,7 +3118,14 @@ export default function Subscription() { setSelectedTraffic(period.traffic.current); } if (period.servers.selected) { - setSelectedServers(period.servers.selected); + const availUuids = new Set( + period.servers.options + ?.filter((s) => s.is_available) + .map((s) => s.uuid) ?? [], + ); + setSelectedServers( + period.servers.selected.filter((uuid) => availUuids.has(uuid)), + ); } if (period.devices.current) { setSelectedDevices(period.devices.current); @@ -3193,8 +3210,9 @@ export default function Subscription() { {currentStep === 'servers' && selectedPeriod?.servers.options && (
{selectedPeriod.servers.options - // Hide Trial server for users who already have trial subscription + // Hide unavailable (disabled) servers and trial servers for existing trial users .filter((server) => { + if (!server.is_available) return false; if (subscription?.is_trial && server.name.toLowerCase().includes('trial')) { return false; } From 026cb476944efffa4b555a8fa3c0a7f52e07a15e Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 4 Feb 2026 01:08:05 +0300 Subject: [PATCH 05/11] Update Subscription.tsx --- src/pages/Subscription.tsx | 178 ++++++++++++++++++------------------- 1 file changed, 89 insertions(+), 89 deletions(-) diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index a846636..00d39a7 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -1645,100 +1645,100 @@ export default function Subscription() {
{countriesData.countries - .filter((country) => country.is_available || country.is_connected) - .map((country) => { - const isCurrentlyConnected = country.is_connected; - const isSelected = selectedServersToUpdate.includes(country.uuid); - const willBeAdded = !isCurrentlyConnected && isSelected; - const willBeRemoved = isCurrentlyConnected && !isSelected; + .filter((country) => country.is_available || country.is_connected) + .map((country) => { + const isCurrentlyConnected = country.is_connected; + const isSelected = selectedServersToUpdate.includes(country.uuid); + const willBeAdded = !isCurrentlyConnected && isSelected; + const willBeRemoved = isCurrentlyConnected && !isSelected; - return ( - - ); - })} + {country.country_code && ( + + {getFlagEmoji(country.country_code)} + + )} + + ); + })}
{(() => { From 6c8d2ad69e0fad922157be493e5b5558c8bb45dd Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 4 Feb 2026 01:17:05 +0300 Subject: [PATCH 06/11] 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 07/11] 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 08/11] 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 09/11] 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": { From 0b127a5f85e042e61975022467be06a67ca03f30 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 4 Feb 2026 02:12:18 +0300 Subject: [PATCH 10/11] Add files via upload --- src/locales/en.json | 9 +++++++++ src/locales/fa.json | 9 +++++++++ src/locales/ru.json | 9 +++++++++ 3 files changed, 27 insertions(+) diff --git a/src/locales/en.json b/src/locales/en.json index 970a201..848fada 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2380,6 +2380,15 @@ "activating": "Activating...", "activate": "Activate", "serverAccess": "Server access" + }, + "deactivate": { + "button": "Cancel promo code", + "confirmTitle": "Cancel promo code?", + "confirmDescription": "Are you sure you want to cancel the {{percent}}% discount? After cancellation, you will be able to activate another promo code.", + "warning": "A cancelled promo code cannot be reused. The discount will stop working immediately.", + "confirm": "Cancel discount", + "deactivating": "Cancelling...", + "error": "Failed to cancel promo code. Please try again later." } }, "telegramRedirect": { diff --git a/src/locales/fa.json b/src/locales/fa.json index 7fca3dc..cbd3a03 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1928,6 +1928,15 @@ "test": "آزمایشی", "activating": "در حال فعال‌سازی...", "activate": "فعال‌سازی" + }, + "deactivate": { + "button": "لغو کد تخفیف", + "confirmTitle": "لغو کد تخفیف؟", + "confirmDescription": "آیا مطمئنید که می‌خواهید تخفیف {{percent}}% را لغو کنید؟ پس از لغو می‌توانید کد تخفیف دیگری فعال کنید.", + "warning": "کد تخفیف لغو شده قابل استفاده مجدد نیست. تخفیف بلافاصله متوقف می‌شود.", + "confirm": "لغو تخفیف", + "deactivating": "در حال لغو...", + "error": "لغو کد تخفیف ناموفق بود. لطفاً بعداً دوباره امتحان کنید." } }, "telegramRedirect": { diff --git a/src/locales/ru.json b/src/locales/ru.json index dfb82df..5ab8231 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3068,6 +3068,15 @@ "test": "Тест", "activating": "Активация...", "activate": "Активировать" + }, + "deactivate": { + "button": "Отменить промокод", + "confirmTitle": "Отменить промокод?", + "confirmDescription": "Вы уверены, что хотите отменить скидку {{percent}}%? После отмены вы сможете активировать другой промокод.", + "warning": "Отменённый промокод нельзя будет использовать повторно. Скидка перестанет действовать немедленно.", + "confirm": "Отменить", + "deactivating": "Отмена...", + "error": "Не удалось отменить промокод. Попробуйте позже." } }, "telegramRedirect": { From dd51552074b114a974f98dd7e2f638aa5075b251 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 4 Feb 2026 03:13:17 +0300 Subject: [PATCH 11/11] Update README.md --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9803329..d22bdc4 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ cabinet.yourdomain.com { # API запросы на backend handle /api/* { uri strip_prefix /api - reverse_proxy backend_bot:8080 + reverse_proxy remnawave_bot:8080 } @websockets { header_regexp Connection *Upgrade* @@ -142,7 +142,7 @@ cabinet.yourdomain.com { # WebSocket соединения handle /cabinet/ws { uri strip_prefix /api - reverse_proxy backend_bot:8080 { + reverse_proxy remnawave_bot:8080 { transport http { read_timeout 0 write_timeout 0 @@ -237,7 +237,7 @@ cabinet.yourdomain.com { # API на backend handle /api/* { uri strip_prefix /api - reverse_proxy backend_bot:8080 + reverse_proxy remnawave_bot:8080 } # Frontend контейнер (nginx внутри на порту 80) @@ -260,7 +260,7 @@ server { # API на backend location /api/ { rewrite ^/api/(.*) /$1 break; - proxy_pass http://backend_bot:8080; + proxy_pass http://remnawave_bot:8080; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -371,7 +371,7 @@ bedolaga-cabinet/ docker exec wget -qO- http://cabinet_frontend:80 # Проверить что backend доступен -docker exec wget -qO- http://backend_bot:8080/health +docker exec wget -qO- http://remnawave_bot:8080/health # Проверить в какой сети находятся контейнеры docker inspect cabinet_frontend | grep -A 10 Networks