From ac89343cc1b0f7d3da7bfe3b5ffc6ac1a84858ec Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 15 Apr 2026 14:04:39 +0300 Subject: [PATCH 1/5] Dev (#394) * Make SBP default and first top-up option (#389) * fix(connection): happ cryptolink flow + ui fixes (#385) * fix(connection): respect happ_cryptolink mode - read connect_mode from connection-link endpoint - auto-redirect to happ cryptolink flow when mode is HAPP_CRYPTOLINK - keep installation guide behavior for other modes * docs(readme): fix source build step 2 - use compose service name for build/create commands - copy static files from created compose container - remove compose container via docker compose rm * fix(connection): handle mode and ws path - treat any non-guide connect mode as direct happ redirect - wait for connection-link response before rendering installation guide - build websocket URL from VITE_API_URL (supports /api and absolute URLs) * docs(readme): fix docker cp static path - copy /usr/share/nginx/html contents via html/. - prevent nested /srv/cabinet/html deployment and 404 on root * fix(connection): keep guide, use happ links - restore guide page behavior for /connection (no auto redirect) - use happ cryptolink URL in /connection/qr when happ crypt mode is active - replace subscription page connection URL with happ cryptolink from connection-link API * fix(connection): avoid wrong redirect flow - force happ scheme deeplink in HAPP_CRYPTOLINK mode for guide connect buttons - use cabinet redirect page only inside Telegram Mini App - open deeplink directly in regular browsers * fix(connection): enforce happ cryptolink - prioritize backend crypt deeplink fields in HAPP_CRYPTOLINK mode - fallback to local crypt4/crypt3 generation from subscription URL when backend returns plain happ://sub... - apply same resolution for guide open action, qr page source and subscription page link display/copy * fix(subscription): truncate long connect link - render connection URL in a single line with ellipsis on /subscriptions - preserve full URL in tooltip for easier manual copy - keep copy button behavior unchanged * fix(connection): build cryptolink from happ sub - allow cryptolink fallback generation from happ://sub... URLs in addition to http(s) - prevent plain happ://sub links from leaking into UI in HAPP_CRYPTOLINK mode * fix: allow saving 0% period discount in promo groups Changed condition from percent > 0 to percent >= 0 so that admins can explicitly set 0% discount for a period. Previously 0% entries were silently dropped and not sent to the backend. * fix: always send period_discounts in promo group updates When all period discounts were removed, the frontend sent undefined (field absent from JSON), so the backend never cleared them. Now always sends the field - empty object {} to clear, or populated object to update. --------- Co-authored-by: zavul0nn <34007368+zavul0nn@users.noreply.github.com> Co-authored-by: Dxnil <62987903+D4nilKO@users.noreply.github.com> --- README.md | 12 +++--- src/api/subscription.ts | 3 ++ src/pages/AdminPromoGroupCreate.tsx | 5 +-- src/pages/Connection.tsx | 65 +++++++++++++++++++++++----- src/pages/Subscription.tsx | 46 +++++++++++++++++--- src/pages/TopUpAmount.tsx | 61 ++++++++++++++++++++++++-- src/providers/WebSocketProvider.tsx | 41 +++++++++++------- src/utils/connectionLink.ts | 67 +++++++++++++++++++++++++++++ 8 files changed, 257 insertions(+), 43 deletions(-) create mode 100644 src/utils/connectionLink.ts diff --git a/README.md b/README.md index f5cb751..cc89889 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,8 @@ docker pull ghcr.io/bedolaga-dev/bedolaga-cabinet:latest ```bash # Создать временный контейнер и скопировать статику docker create --name tmp_cabinet ghcr.io/bedolaga-dev/bedolaga-cabinet:latest -docker cp tmp_cabinet:/usr/share/nginx/html ./cabinet-dist +mkdir -p ./cabinet-dist +docker cp tmp_cabinet:/usr/share/nginx/html/. ./cabinet-dist/ docker rm tmp_cabinet ``` @@ -77,10 +78,11 @@ VITE_APP_LOGO=V Соберите и извлеките: ```bash -docker compose build -docker create --name tmp_cabinet cabinet_frontend -docker cp tmp_cabinet:/usr/share/nginx/html ./cabinet-dist -docker rm tmp_cabinet +docker compose build cabinet-frontend +docker compose create cabinet-frontend +mkdir -p ./cabinet-dist +docker cp cabinet_frontend:/usr/share/nginx/html/. ./cabinet-dist/ +docker compose rm -sf cabinet-frontend ``` ### Шаг 3. Размещение файлов на сервере diff --git a/src/api/subscription.ts b/src/api/subscription.ts index 529e934..2af14bf 100644 --- a/src/api/subscription.ts +++ b/src/api/subscription.ts @@ -449,6 +449,9 @@ export const subscriptionApi = { display_link: string | null; happ_redirect_link: string | null; happ_scheme_link: string | null; + happ_cryptolink?: string | null; + happ_crypto_link?: string | null; + happ_link?: string | null; connect_mode: string; hide_link: boolean; instructions: { diff --git a/src/pages/AdminPromoGroupCreate.tsx b/src/pages/AdminPromoGroupCreate.tsx index a9ee620..11d7416 100644 --- a/src/pages/AdminPromoGroupCreate.tsx +++ b/src/pages/AdminPromoGroupCreate.tsx @@ -135,7 +135,7 @@ export default function AdminPromoGroupCreate() { periodDiscounts.forEach((pd) => { const days = pd.days === '' ? 0 : pd.days; const percent = pd.percent === '' ? 0 : pd.percent; - if (days > 0 && percent > 0) { + if (days > 0 && percent >= 0) { periodDiscountsRecord[days] = percent; } }); @@ -150,8 +150,7 @@ export default function AdminPromoGroupCreate() { server_discount_percent: serverVal, traffic_discount_percent: trafficVal, device_discount_percent: deviceVal, - period_discounts: - Object.keys(periodDiscountsRecord).length > 0 ? periodDiscountsRecord : undefined, + period_discounts: periodDiscountsRecord, apply_discounts_to_addons: applyToAddons, auto_assign_total_spent_kopeks: autoAssignVal > 0 ? Math.round(autoAssignVal * 100) : 0, is_default: isDefault, diff --git a/src/pages/Connection.tsx b/src/pages/Connection.tsx index d22e5f4..a36c049 100644 --- a/src/pages/Connection.tsx +++ b/src/pages/Connection.tsx @@ -7,6 +7,7 @@ import { subscriptionApi } from '../api/subscription'; import { useTelegramSDK } from '../hooks/useTelegramSDK'; import { useHaptic } from '@/platform'; import { resolveTemplate, hasTemplates } from '../utils/templateEngine'; +import { isHappCryptolinkMode, resolveConnectionUrlForUi } from '../utils/connectionLink'; import { useAuthStore } from '../store/auth'; import type { AppConfig, RemnawavePlatformData } from '../types'; import InstallationGuide from '../components/connection/InstallationGuide'; @@ -32,21 +33,59 @@ export default function Connection() { queryKey: ['appConfig', subId], queryFn: () => subscriptionApi.getAppConfig(subId), }); + const { data: connectionLink, isLoading: isConnectionLinkLoading } = useQuery({ + queryKey: ['connectionLink', subId], + queryFn: () => subscriptionApi.getConnectionLink(subId), + retry: false, + staleTime: 0, + }); + + const qrConnectionUrl = useMemo( + () => + resolveConnectionUrlForUi({ + mode: connectionLink?.connect_mode, + happSchemeLink: connectionLink?.happ_scheme_link, + displayLink: connectionLink?.display_link, + subscriptionUrl: connectionLink?.subscription_url, + happCryptLink: connectionLink?.happ_cryptolink, + happCryptoLink: connectionLink?.happ_crypto_link, + happLink: connectionLink?.happ_link, + fallbackUrl: appConfig?.subscriptionUrl, + }), + [ + appConfig?.subscriptionUrl, + connectionLink?.connect_mode, + connectionLink?.display_link, + connectionLink?.happ_cryptolink, + connectionLink?.happ_crypto_link, + connectionLink?.happ_link, + connectionLink?.happ_scheme_link, + connectionLink?.subscription_url, + ], + ); const handleGoBack = useCallback(() => { navigate(-1); }, [navigate]); const handleOpenQR = useCallback(() => { + if (!qrConnectionUrl) return; navigate('/connection/qr', { replace: !isTelegramWebApp, state: { - url: appConfig?.subscriptionUrl, - hideLink: appConfig?.hideLink ?? false, + url: qrConnectionUrl, + hideLink: connectionLink?.hide_link ?? appConfig?.hideLink ?? false, subscriptionId: subId, }, }); - }, [navigate, appConfig?.subscriptionUrl, appConfig?.hideLink, isTelegramWebApp, subId]); + }, [ + navigate, + qrConnectionUrl, + connectionLink?.hide_link, + appConfig?.hideLink, + isTelegramWebApp, + subId, + ]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -73,24 +112,30 @@ export default function Connection() { const openDeepLink = useCallback( (deepLink: string) => { let resolved = deepLink; - if (hasTemplates(resolved)) { + if (isHappCryptolinkMode(connectionLink?.connect_mode) && qrConnectionUrl) { + // In HAPP cryptolink mode always open the resolved happ://crypt... URL. + resolved = qrConnectionUrl; + } else if (hasTemplates(resolved)) { resolved = resolveUrl(resolved); } - - const finalUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(resolved)}&lang=${i18n.language || 'en'}`; + const isHttpUrl = /^https?:\/\//i.test(resolved); + const finalUrlForTelegram = isHttpUrl + ? resolved + : `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(resolved)}&lang=${i18n.language || 'en'}`; if (isTelegramWebApp) { try { - sdkOpenLink(finalUrl, { tryInstantView: false }); + sdkOpenLink(finalUrlForTelegram, { tryInstantView: false }); return; } catch { // SDK not available, fallback } } - window.location.href = finalUrl; + // In regular browsers open deeplink directly (without intermediate redirect page). + window.location.href = resolved; }, - [isTelegramWebApp, i18n.language, resolveUrl], + [isTelegramWebApp, i18n.language, resolveUrl, connectionLink?.connect_mode, qrConnectionUrl], ); // Check if any platform has configured apps @@ -101,7 +146,7 @@ export default function Connection() { ); }, [appConfig?.platforms]); - if (isLoading) { + if (isLoading || isConnectionLinkLoading) { return (
diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 9f3a14e..e8ffbeb 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback, memo } from 'react'; +import { useState, useEffect, useRef, useCallback, useMemo, memo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { Navigate, useNavigate, useParams } from 'react-router'; @@ -18,6 +18,7 @@ import { useCloseOnSuccessNotification } from '../store/successNotification'; import PurchaseCTAButton from '../components/subscription/PurchaseCTAButton'; import { CopyIcon, CheckIcon } from '../components/icons'; import { useHaptic } from '../platform'; +import { resolveConnectionUrlForUi } from '../utils/connectionLink'; import { getErrorMessage, getInsufficientBalanceError, @@ -221,9 +222,41 @@ export default function Subscription() { staleTime: 0, refetchOnMount: 'always', }); + const { data: connectionLink, isLoading: isConnectionLinkLoading } = useQuery({ + queryKey: ['connection-link', subscriptionId], + queryFn: () => subscriptionApi.getConnectionLink(subscriptionId), + retry: false, + staleTime: 0, + }); // Extract subscription from response (null if no subscription) const subscription = subscriptionResponse?.subscription ?? null; + const displayedConnectionUrl = useMemo( + () => + resolveConnectionUrlForUi({ + mode: connectionLink?.connect_mode, + happSchemeLink: connectionLink?.happ_scheme_link, + displayLink: connectionLink?.display_link, + subscriptionUrl: connectionLink?.subscription_url, + happCryptLink: connectionLink?.happ_cryptolink, + happCryptoLink: connectionLink?.happ_crypto_link, + happLink: connectionLink?.happ_link, + fallbackUrl: isConnectionLinkLoading ? null : (subscription?.subscription_url ?? null), + }), + [ + connectionLink?.connect_mode, + connectionLink?.display_link, + connectionLink?.happ_cryptolink, + connectionLink?.happ_crypto_link, + connectionLink?.happ_link, + connectionLink?.happ_scheme_link, + connectionLink?.subscription_url, + isConnectionLinkLoading, + subscription?.subscription_url, + ], + ); + const shouldHideConnectionLink = + subscription?.hide_subscription_link || connectionLink?.hide_link; // Traffic zone (theme-aware) — called unconditionally at top level const usedPercent = trafficData?.traffic_used_percent ?? subscription?.traffic_used_percent ?? 0; @@ -453,8 +486,8 @@ export default function Subscription() { }, [subscription, refreshTrafficMutation, subscriptionId]); const copyUrl = () => { - if (subscription?.subscription_url) { - navigator.clipboard.writeText(subscription.subscription_url); + if (displayedConnectionUrl) { + navigator.clipboard.writeText(displayedConnectionUrl); setCopied(true); setTimeout(() => setCopied(false), 2000); } @@ -890,16 +923,17 @@ export default function Subscription() { )} {/* ─── Subscription URL ─── */} - {subscription.subscription_url && !subscription.hide_subscription_link && ( + {displayedConnectionUrl && !shouldHideConnectionLink && (
- {subscription.subscription_url} + {displayedConnectionUrl}