* 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>
This commit is contained in:
Egor
2026-04-15 14:04:39 +03:00
committed by GitHub
parent 1f833dbdd5
commit ac89343cc1
8 changed files with 257 additions and 43 deletions

View File

@@ -53,7 +53,8 @@ docker pull ghcr.io/bedolaga-dev/bedolaga-cabinet:latest
```bash ```bash
# Создать временный контейнер и скопировать статику # Создать временный контейнер и скопировать статику
docker create --name tmp_cabinet ghcr.io/bedolaga-dev/bedolaga-cabinet:latest 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 docker rm tmp_cabinet
``` ```
@@ -77,10 +78,11 @@ VITE_APP_LOGO=V
Соберите и извлеките: Соберите и извлеките:
```bash ```bash
docker compose build docker compose build cabinet-frontend
docker create --name tmp_cabinet cabinet_frontend docker compose create cabinet-frontend
docker cp tmp_cabinet:/usr/share/nginx/html ./cabinet-dist mkdir -p ./cabinet-dist
docker rm tmp_cabinet docker cp cabinet_frontend:/usr/share/nginx/html/. ./cabinet-dist/
docker compose rm -sf cabinet-frontend
``` ```
### Шаг 3. Размещение файлов на сервере ### Шаг 3. Размещение файлов на сервере

View File

@@ -449,6 +449,9 @@ export const subscriptionApi = {
display_link: string | null; display_link: string | null;
happ_redirect_link: string | null; happ_redirect_link: string | null;
happ_scheme_link: string | null; happ_scheme_link: string | null;
happ_cryptolink?: string | null;
happ_crypto_link?: string | null;
happ_link?: string | null;
connect_mode: string; connect_mode: string;
hide_link: boolean; hide_link: boolean;
instructions: { instructions: {

View File

@@ -135,7 +135,7 @@ export default function AdminPromoGroupCreate() {
periodDiscounts.forEach((pd) => { periodDiscounts.forEach((pd) => {
const days = pd.days === '' ? 0 : pd.days; const days = pd.days === '' ? 0 : pd.days;
const percent = pd.percent === '' ? 0 : pd.percent; const percent = pd.percent === '' ? 0 : pd.percent;
if (days > 0 && percent > 0) { if (days > 0 && percent >= 0) {
periodDiscountsRecord[days] = percent; periodDiscountsRecord[days] = percent;
} }
}); });
@@ -150,8 +150,7 @@ export default function AdminPromoGroupCreate() {
server_discount_percent: serverVal, server_discount_percent: serverVal,
traffic_discount_percent: trafficVal, traffic_discount_percent: trafficVal,
device_discount_percent: deviceVal, device_discount_percent: deviceVal,
period_discounts: period_discounts: periodDiscountsRecord,
Object.keys(periodDiscountsRecord).length > 0 ? periodDiscountsRecord : undefined,
apply_discounts_to_addons: applyToAddons, apply_discounts_to_addons: applyToAddons,
auto_assign_total_spent_kopeks: autoAssignVal > 0 ? Math.round(autoAssignVal * 100) : 0, auto_assign_total_spent_kopeks: autoAssignVal > 0 ? Math.round(autoAssignVal * 100) : 0,
is_default: isDefault, is_default: isDefault,

View File

@@ -7,6 +7,7 @@ import { subscriptionApi } from '../api/subscription';
import { useTelegramSDK } from '../hooks/useTelegramSDK'; import { useTelegramSDK } from '../hooks/useTelegramSDK';
import { useHaptic } from '@/platform'; import { useHaptic } from '@/platform';
import { resolveTemplate, hasTemplates } from '../utils/templateEngine'; import { resolveTemplate, hasTemplates } from '../utils/templateEngine';
import { isHappCryptolinkMode, resolveConnectionUrlForUi } from '../utils/connectionLink';
import { useAuthStore } from '../store/auth'; import { useAuthStore } from '../store/auth';
import type { AppConfig, RemnawavePlatformData } from '../types'; import type { AppConfig, RemnawavePlatformData } from '../types';
import InstallationGuide from '../components/connection/InstallationGuide'; import InstallationGuide from '../components/connection/InstallationGuide';
@@ -32,21 +33,59 @@ export default function Connection() {
queryKey: ['appConfig', subId], queryKey: ['appConfig', subId],
queryFn: () => subscriptionApi.getAppConfig(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(() => { const handleGoBack = useCallback(() => {
navigate(-1); navigate(-1);
}, [navigate]); }, [navigate]);
const handleOpenQR = useCallback(() => { const handleOpenQR = useCallback(() => {
if (!qrConnectionUrl) return;
navigate('/connection/qr', { navigate('/connection/qr', {
replace: !isTelegramWebApp, replace: !isTelegramWebApp,
state: { state: {
url: appConfig?.subscriptionUrl, url: qrConnectionUrl,
hideLink: appConfig?.hideLink ?? false, hideLink: connectionLink?.hide_link ?? appConfig?.hideLink ?? false,
subscriptionId: subId, subscriptionId: subId,
}, },
}); });
}, [navigate, appConfig?.subscriptionUrl, appConfig?.hideLink, isTelegramWebApp, subId]); }, [
navigate,
qrConnectionUrl,
connectionLink?.hide_link,
appConfig?.hideLink,
isTelegramWebApp,
subId,
]);
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
@@ -73,24 +112,30 @@ export default function Connection() {
const openDeepLink = useCallback( const openDeepLink = useCallback(
(deepLink: string) => { (deepLink: string) => {
let resolved = deepLink; 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); resolved = resolveUrl(resolved);
} }
const isHttpUrl = /^https?:\/\//i.test(resolved);
const finalUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(resolved)}&lang=${i18n.language || 'en'}`; const finalUrlForTelegram = isHttpUrl
? resolved
: `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(resolved)}&lang=${i18n.language || 'en'}`;
if (isTelegramWebApp) { if (isTelegramWebApp) {
try { try {
sdkOpenLink(finalUrl, { tryInstantView: false }); sdkOpenLink(finalUrlForTelegram, { tryInstantView: false });
return; return;
} catch { } catch {
// SDK not available, fallback // 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 // Check if any platform has configured apps
@@ -101,7 +146,7 @@ export default function Connection() {
); );
}, [appConfig?.platforms]); }, [appConfig?.platforms]);
if (isLoading) { if (isLoading || isConnectionLinkLoading) {
return ( return (
<div className="flex flex-1 items-center justify-center py-20"> <div className="flex flex-1 items-center justify-center py-20">
<div className="h-10 w-10 animate-spin rounded-full border-[3px] border-accent-500/30 border-t-accent-500" /> <div className="h-10 w-10 animate-spin rounded-full border-[3px] border-accent-500/30 border-t-accent-500" />

View File

@@ -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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Navigate, useNavigate, useParams } from 'react-router'; import { Navigate, useNavigate, useParams } from 'react-router';
@@ -18,6 +18,7 @@ import { useCloseOnSuccessNotification } from '../store/successNotification';
import PurchaseCTAButton from '../components/subscription/PurchaseCTAButton'; import PurchaseCTAButton from '../components/subscription/PurchaseCTAButton';
import { CopyIcon, CheckIcon } from '../components/icons'; import { CopyIcon, CheckIcon } from '../components/icons';
import { useHaptic } from '../platform'; import { useHaptic } from '../platform';
import { resolveConnectionUrlForUi } from '../utils/connectionLink';
import { import {
getErrorMessage, getErrorMessage,
getInsufficientBalanceError, getInsufficientBalanceError,
@@ -221,9 +222,41 @@ export default function Subscription() {
staleTime: 0, staleTime: 0,
refetchOnMount: 'always', 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) // Extract subscription from response (null if no subscription)
const subscription = subscriptionResponse?.subscription ?? null; 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 // Traffic zone (theme-aware) — called unconditionally at top level
const usedPercent = trafficData?.traffic_used_percent ?? subscription?.traffic_used_percent ?? 0; const usedPercent = trafficData?.traffic_used_percent ?? subscription?.traffic_used_percent ?? 0;
@@ -453,8 +486,8 @@ export default function Subscription() {
}, [subscription, refreshTrafficMutation, subscriptionId]); }, [subscription, refreshTrafficMutation, subscriptionId]);
const copyUrl = () => { const copyUrl = () => {
if (subscription?.subscription_url) { if (displayedConnectionUrl) {
navigator.clipboard.writeText(subscription.subscription_url); navigator.clipboard.writeText(displayedConnectionUrl);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} }
@@ -890,16 +923,17 @@ export default function Subscription() {
)} )}
{/* ─── Subscription URL ─── */} {/* ─── Subscription URL ─── */}
{subscription.subscription_url && !subscription.hide_subscription_link && ( {displayedConnectionUrl && !shouldHideConnectionLink && (
<div className="mb-5 flex gap-2"> <div className="mb-5 flex gap-2">
<code <code
className="scrollbar-hide flex-1 overflow-x-auto break-all rounded-[10px] px-3 py-2 font-mono text-[11px] text-dark-50/30" className="block min-w-0 flex-1 truncate whitespace-nowrap rounded-[10px] px-3 py-2 font-mono text-[11px] text-dark-50/30"
style={{ style={{
background: g.codeBg, background: g.codeBg,
border: `1px solid ${g.codeBorder}`, border: `1px solid ${g.codeBorder}`,
}} }}
title={displayedConnectionUrl}
> >
{subscription.subscription_url} {displayedConnectionUrl}
</code> </code>
<button <button
onClick={copyUrl} onClick={copyUrl}

View File

@@ -10,7 +10,7 @@ import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils
import { useCloseOnSuccessNotification } from '../store/successNotification'; import { useCloseOnSuccessNotification } from '../store/successNotification';
import { useHaptic, usePlatform } from '@/platform'; import { useHaptic, usePlatform } from '@/platform';
import { staggerContainer, staggerItem } from '@/components/motion/transitions'; import { staggerContainer, staggerItem } from '@/components/motion/transitions';
import type { PaymentMethod } from '../types'; import type { PaymentMethod, PaymentMethodOption } from '../types';
import BentoCard from '../components/ui/BentoCard'; import BentoCard from '../components/ui/BentoCard';
import { saveTopUpPendingInfo } from '../utils/topUpStorage'; import { saveTopUpPendingInfo } from '../utils/topUpStorage';
@@ -80,6 +80,44 @@ const getMethodIcon = (methodId: string) => {
return <CardIcon />; return <CardIcon />;
}; };
const getPreferredOptionId = (options?: PaymentMethod['options']) => {
if (!options || options.length === 0) return null;
const sbpOption = options.find((option) => {
const normalizedId = option.id.toLowerCase();
const normalizedName = option.name.toLowerCase();
return (
normalizedId.includes('sbp') ||
normalizedName.includes('сбп') ||
normalizedName.includes('sbp')
);
});
return sbpOption?.id ?? options[0].id;
};
const sortOptionsWithSbpFirst = (options?: PaymentMethod['options']) => {
if (!options || options.length <= 1) return options ?? [];
const isPreferredOption = (option: PaymentMethodOption) => {
const normalizedId = option.id.toLowerCase();
const normalizedName = option.name.toLowerCase();
return (
normalizedId.includes('sbp') ||
normalizedName.includes('сбп') ||
normalizedName.includes('sbp')
);
};
return [...options].sort((left, right) => {
const leftIsPreferred = isPreferredOption(left);
const rightIsPreferred = isPreferredOption(right);
if (leftIsPreferred === rightIsPreferred) return 0;
return leftIsPreferred ? -1 : 1;
});
};
export default function TopUpAmount() { export default function TopUpAmount() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -135,7 +173,7 @@ export default function TopUpAmount() {
const [amount, setAmount] = useState(getInitialAmount); const [amount, setAmount] = useState(getInitialAmount);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedOption, setSelectedOption] = useState<string | null>( const [selectedOption, setSelectedOption] = useState<string | null>(
method?.options && method.options.length > 0 ? method.options[0].id : null, getPreferredOptionId(method?.options),
); );
const [paymentUrl, setPaymentUrl] = useState<string | null>(null); const [paymentUrl, setPaymentUrl] = useState<string | null>(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -154,6 +192,20 @@ export default function TopUpAmount() {
} }
}, [cachedMethods, method, navigate, searchParams]); }, [cachedMethods, method, navigate, searchParams]);
useEffect(() => {
if (!method?.options || method.options.length === 0) {
if (selectedOption !== null) {
setSelectedOption(null);
}
return;
}
const optionExists = method.options.some((option) => option.id === selectedOption);
if (!optionExists) {
setSelectedOption(getPreferredOptionId(method.options));
}
}, [method?.id, method?.options, selectedOption]);
const starsPaymentMutation = useMutation({ const starsPaymentMutation = useMutation({
mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks), mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks),
onSuccess: async (data) => { onSuccess: async (data) => {
@@ -248,6 +300,7 @@ export default function TopUpAmount() {
} }
const hasOptions = method.options && method.options.length > 0; const hasOptions = method.options && method.options.length > 0;
const orderedOptions = sortOptionsWithSbpFirst(method.options);
const minRubles = method.min_amount_kopeks / 100; const minRubles = method.min_amount_kopeks / 100;
const maxRubles = method.max_amount_kopeks / 100; const maxRubles = method.max_amount_kopeks / 100;
const methodKey = method.id.toLowerCase().replace(/-/g, '_'); const methodKey = method.id.toLowerCase().replace(/-/g, '_');
@@ -344,11 +397,11 @@ export default function TopUpAmount() {
</motion.div> </motion.div>
{/* Payment options (if any) */} {/* Payment options (if any) */}
{hasOptions && method.options && ( {hasOptions && orderedOptions.length > 0 && (
<motion.div variants={staggerItem} className="space-y-2"> <motion.div variants={staggerItem} className="space-y-2">
<label className="text-sm font-medium text-dark-400">{t('balance.paymentMethod')}</label> <label className="text-sm font-medium text-dark-400">{t('balance.paymentMethod')}</label>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{method.options.map((opt) => ( {orderedOptions.map((opt) => (
<button <button
key={opt.id} key={opt.id}
type="button" type="button"

View File

@@ -8,6 +8,31 @@ export type { WSMessage } from './WebSocketContext';
const isDev = import.meta.env.DEV; const isDev = import.meta.env.DEV;
function buildWebSocketUrl(accessToken: string): string {
const apiUrl = String(import.meta.env.VITE_API_URL || '/api').trim();
const windowWsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const withToken = (base: string) => `${base}?token=${encodeURIComponent(accessToken)}`;
if (apiUrl.startsWith('http://') || apiUrl.startsWith('https://')) {
try {
const api = new URL(apiUrl);
const wsProtocol = api.protocol === 'https:' ? 'wss:' : 'ws:';
const basePath = api.pathname.replace(/\/+$/, '');
const wsPath = basePath.endsWith('/cabinet') ? `${basePath}/ws` : `${basePath}/cabinet/ws`;
return withToken(`${wsProtocol}//${api.host}${wsPath}`);
} catch {
// fall through to relative-path handling
}
}
const normalizedBasePath = `/${apiUrl}`.replace(/\/{2,}/g, '/').replace(/\/+$/, '');
const wsPath = normalizedBasePath.endsWith('/cabinet')
? `${normalizedBasePath}/ws`
: `${normalizedBasePath}/cabinet/ws`;
return withToken(`${windowWsProtocol}//${window.location.host}${wsPath}`);
}
export function WebSocketProvider({ children }: { children: React.ReactNode }) { export function WebSocketProvider({ children }: { children: React.ReactNode }) {
const accessToken = useAuthStore((state) => state.accessToken); const accessToken = useAuthStore((state) => state.accessToken);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
@@ -48,21 +73,7 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
cleanup(); cleanup();
// Build WebSocket URL const wsUrl = buildWebSocketUrl(accessToken);
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
let host = window.location.host;
// Handle VITE_API_URL - can be absolute URL or relative path
const apiUrl = import.meta.env.VITE_API_URL;
if (apiUrl && (apiUrl.startsWith('http://') || apiUrl.startsWith('https://'))) {
try {
host = new URL(apiUrl).host;
} catch {
// If URL parsing fails, use window.location.host
}
}
const wsUrl = `${protocol}//${host}/cabinet/ws?token=${accessToken}`;
try { try {
const ws = new WebSocket(wsUrl); const ws = new WebSocket(wsUrl);

View File

@@ -0,0 +1,67 @@
import { createHappCryptoLink } from '@kastov/cryptohapp';
export function isHappCryptolinkMode(mode: string | null | undefined): boolean {
const normalized = String(mode ?? '').toUpperCase();
if (!normalized) return false;
return normalized.includes('HAPP') && normalized.includes('CRYPT');
}
function isHttpUrl(url: string | null | undefined): url is string {
return typeof url === 'string' && /^https?:\/\//i.test(url);
}
function isHappSubscriptionLink(url: string | null | undefined): url is string {
return typeof url === 'string' && /^happ:\/\/sub/i.test(url);
}
function isCryptSourceUrl(url: string | null | undefined): url is string {
return isHttpUrl(url) || isHappSubscriptionLink(url);
}
function isHappCryptDeepLink(url: string | null | undefined): url is string {
return typeof url === 'string' && /^happ:\/\/crypt/i.test(url);
}
interface ResolveConnectionUrlInput {
mode?: string | null;
subscriptionUrl?: string | null;
displayLink?: string | null;
happSchemeLink?: string | null;
happCryptLink?: string | null;
happCryptoLink?: string | null;
happLink?: string | null;
fallbackUrl?: string | null;
}
export function resolveConnectionUrlForUi(input: ResolveConnectionUrlInput): string | null {
const defaultUrl =
input.fallbackUrl ?? input.subscriptionUrl ?? input.displayLink ?? input.happSchemeLink ?? null;
if (!isHappCryptolinkMode(input.mode)) return defaultUrl;
const backendCryptLink =
[
input.happCryptLink,
input.happCryptoLink,
input.happLink,
input.happSchemeLink,
input.displayLink,
input.subscriptionUrl,
].find((value) => isHappCryptDeepLink(value)) ?? null;
if (backendCryptLink) return backendCryptLink;
const sourceSubscriptionUrl =
[input.subscriptionUrl, input.displayLink, input.fallbackUrl].find((value) =>
isCryptSourceUrl(value),
) ?? null;
if (sourceSubscriptionUrl) {
return (
createHappCryptoLink(sourceSubscriptionUrl, 'v4', true) ??
createHappCryptoLink(sourceSubscriptionUrl, 'v3', true) ??
defaultUrl
);
}
return defaultUrl;
}