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 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; + }, }; diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 780f7a2..57254a1 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -292,13 +292,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 }); diff --git a/src/components/PromoDiscountBadge.tsx b/src/components/PromoDiscountBadge.tsx new file mode 100644 index 0000000..16301d0 --- /dev/null +++ b/src/components/PromoDiscountBadge.tsx @@ -0,0 +1,378 @@ +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'; + +const SparklesIcon = () => ( + + + +); + +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 + 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 ''; + + 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')}`; +}; + +// ------- 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({ + queryKey: ['active-discount'], + queryFn: promoApi.getActiveDiscount, + staleTime: 30000, + 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) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Don't render if no active discount + if (!activeDiscount || !activeDiscount.is_active || !activeDiscount.discount_percent) { + return null; + } + + const timeLeft = activeDiscount.expires_at ? formatTimeLeft(activeDiscount.expires_at, t) : null; + + const handleClick = () => { + setIsOpen(!isOpen); + }; + + const handleGoToSubscription = () => { + setIsOpen(false); + navigate('/subscription'); + }; + + const handleDeactivateClick = () => { + setDeactivateError(null); + setShowConfirmModal(true); + }; + + return ( + <> +
+ {/* Badge button */} + + + {/* Dropdown - mobile: fixed centered, desktop: absolute right */} + {isOpen && ( + <> + {/* Mobile backdrop */} +
setIsOpen(false)} + /> + +
+ {/* 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 */} + +
+
+ + )} +
+ + {/* Deactivation Confirmation Modal */} + + + ); +} diff --git a/src/locales/en.json b/src/locales/en.json index 23ec6f1..c2cebf0 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -322,7 +322,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." }, @@ -2567,6 +2567,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 7ec6825..83a82c4 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -2093,6 +2093,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 a6064db..0e7fa9a 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -344,7 +344,7 @@ "title": "Выберите тариф для продолжения", "description": "Ваш пробный период скоро закончится. Выберите подходящий тариф, чтобы продолжить пользоваться VPN без ограничений." }, - "expired": { + "expiredBanner": { "title": "Подписка истекла", "selectTariff": "Ваша подписка истекла. Выберите тариф ниже, чтобы возобновить доступ к VPN." }, @@ -3280,6 +3280,15 @@ "test": "Тест", "activating": "Активация...", "activate": "Активировать" + }, + "deactivate": { + "button": "Отменить промокод", + "confirmTitle": "Отменить промокод?", + "confirmDescription": "Вы уверены, что хотите отменить скидку {{percent}}%? После отмены вы сможете активировать другой промокод.", + "warning": "Отменённый промокод нельзя будет использовать повторно. Скидка перестанет действовать немедленно.", + "confirm": "Отменить", + "deactivating": "Отмена...", + "error": "Не удалось отменить промокод. Попробуйте позже." } }, "telegramRedirect": { diff --git a/src/locales/zh.json b/src/locales/zh.json index bf4e24b..bc5c0a2 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -2092,6 +2092,15 @@ "test": "测试", "activating": "激活中...", "activate": "激活" + }, + "deactivate": { + "button": "取消优惠码", + "confirmTitle": "取消优惠码?", + "confirmDescription": "您确定要取消 {{percent}}% 的折扣吗?取消后您可以激活其他优惠码。", + "warning": "已取消的优惠码无法重新使用。折扣将立即失效。", + "confirm": "取消折扣", + "deactivating": "取消中...", + "error": "取消优惠码失败,请稍后重试。" } }, "telegramRedirect": { diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index bc0bcb9..1f8e048 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]); @@ -1637,99 +1645,101 @@ export default function Subscription() { )}
- {countriesData.countries.map((country) => { - const isCurrentlyConnected = country.is_connected; - const isSelected = selectedServersToUpdate.includes(country.uuid); - const willBeAdded = !isCurrentlyConnected && isSelected; - const willBeRemoved = isCurrentlyConnected && !isSelected; + {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; - return ( - - ); - })} + {country.country_code && ( + + {getFlagEmoji(country.country_code)} + + )} + + ); + })}
{(() => { @@ -2002,10 +2012,10 @@ export default function Subscription() {
- {t('subscription.expired.title')} + {t('subscription.expiredBanner.title')}
- {t('subscription.expired.selectTariff')} + {t('subscription.expiredBanner.selectTariff')}
@@ -3109,7 +3119,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); @@ -3123,7 +3140,7 @@ export default function Subscription() { > {displayDiscount && displayDiscount > 0 && (
@@ -3131,7 +3148,7 @@ export default function Subscription() {
)}
{period.label}
-
+
{formatPrice(promoPeriod.price)} @@ -3170,20 +3187,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)} + + )} +
+ + ); + })()} ); })} @@ -3194,8 +3232,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; } @@ -3223,11 +3262,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; + })()}