diff --git a/miniapp/index.html b/miniapp/index.html index 322f5f83..7bcad499 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -254,6 +254,18 @@ z-index: 1; } + .logo-icon.has-image { + font-size: 0; + line-height: 0; + } + + .logo-icon.has-image img { + width: 60px; + height: 60px; + object-fit: contain; + display: block; + } + .logo { font-size: 28px; font-weight: 800; @@ -3853,6 +3865,13 @@ margin-bottom: 4px; } + .app-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + } + .featured-badge { display: inline-block; background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.8)); @@ -3865,6 +3884,19 @@ letter-spacing: 0.5px; } + .app-platform { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 20px; + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; + text-transform: none; + letter-spacing: 0; + } + .app-steps { margin-top: 16px; } @@ -5494,7 +5526,15 @@ // All original constants and functions const LANG_STORAGE_KEY = 'remnawave-miniapp-language'; - const SUPPORTED_LANGUAGES = ['en', 'ru']; + const DEFAULT_SUPPORTED_LANGUAGES = ['en', 'ru']; + const BASE_LANGUAGE_ORDER = ['en', 'ru']; + const supportedLanguages = new Set(DEFAULT_SUPPORTED_LANGUAGES); + const LANGUAGE_LABELS = { + en: '🇬🇧 English', + ru: '🇷🇺 Русский', + zh: '🇨🇳 中文', + fa: '🇮🇷 فارسی', + }; const translations = { en: { @@ -5739,8 +5779,11 @@ 'apps.no_data': 'No installation guide available for this platform yet.', 'apps.featured': 'Recommended', 'apps.step.download': 'Download & install', + 'apps.step.before_add': 'Before adding subscription', 'apps.step.add': 'Add subscription', + 'apps.step.after_add': 'If the subscription is not added', 'apps.step.connect': 'Connect & use', + 'apps.button.open_link': 'Open link', 'faq.title': 'FAQ', 'faq.item_default_title': 'Question {index}', 'faq.item_empty': 'Answer will be added soon.', @@ -5862,6 +5905,11 @@ 'platform.android': 'Android', 'platform.pc': 'PC', 'platform.tv': 'TV', + 'platform.windows': 'Windows', + 'platform.macos': 'macOS', + 'platform.linux': 'Linux', + 'platform.android_tv': 'Android TV', + 'platform.apple_tv': 'Apple TV', 'units.gb': 'GB', 'values.unlimited': 'Unlimited', 'values.not_available': 'Not available', @@ -6134,8 +6182,11 @@ 'apps.no_data': 'Для этой платформы инструкция пока недоступна.', 'apps.featured': 'Рекомендуем', 'apps.step.download': 'Скачать и установить', + 'apps.step.before_add': 'Перед добавлением подписки', 'apps.step.add': 'Добавить подписку', + 'apps.step.after_add': 'Если подписка не добавилась', 'apps.step.connect': 'Подключиться и пользоваться', + 'apps.button.open_link': 'Открыть ссылку', 'faq.title': 'FAQ', 'faq.item_default_title': 'Вопрос {index}', 'faq.item_empty': 'Ответ будет добавлен позже.', @@ -6257,6 +6308,11 @@ 'platform.android': 'Android', 'platform.pc': 'ПК', 'platform.tv': 'ТВ', + 'platform.windows': 'Windows', + 'platform.macos': 'macOS', + 'platform.linux': 'Linux', + 'platform.android_tv': 'Android TV', + 'platform.apple_tv': 'Apple TV', 'units.gb': 'ГБ', 'values.unlimited': 'Безлимит', 'values.not_available': 'Недоступно', @@ -6308,6 +6364,101 @@ Object.assign(translations[lang], pcTranslations[lang]); }); + function getLanguageLabel(code) { + const normalized = String(code ?? '').toLowerCase(); + if (!normalized) { + return ''; + } + const base = normalized.split('-')[0]; + return LANGUAGE_LABELS[normalized] || LANGUAGE_LABELS[base] || normalized.toUpperCase(); + } + + function getSupportedLanguages() { + return Array.from(supportedLanguages); + } + + function addSupportedLanguages(locales = []) { + let updated = false; + locales.forEach(locale => { + if (!locale) { + return; + } + const normalized = String(locale).toLowerCase(); + if (!normalized) { + return; + } + if (!supportedLanguages.has(normalized)) { + supportedLanguages.add(normalized); + updated = true; + } + }); + return updated; + } + + function renderLanguageSelectOptions() { + const select = document.getElementById('languageSelect'); + if (!select) { + return; + } + + const normalizedPreferred = resolveLanguage(preferredLanguage) || 'en'; + if (!supportedLanguages.has(normalizedPreferred)) { + supportedLanguages.add(normalizedPreferred); + } + + const baseOrder = BASE_LANGUAGE_ORDER.map(lang => lang.toLowerCase()); + const extras = getSupportedLanguages() + .map(lang => lang.toLowerCase()) + .filter(lang => !baseOrder.includes(lang)); + extras.sort((a, b) => a.localeCompare(b)); + + const order = [...baseOrder]; + extras.forEach(lang => { + if (!order.includes(lang)) { + order.push(lang); + } + }); + if (!order.includes(normalizedPreferred)) { + order.push(normalizedPreferred); + } + + const seen = new Set(); + const optionsHtml = order.map(lang => { + const normalized = String(lang).toLowerCase(); + if (!normalized || seen.has(normalized)) { + return ''; + } + seen.add(normalized); + const value = escapeHtml(normalized); + const label = escapeHtml(getLanguageLabel(normalized)); + return ``; + }).filter(Boolean).join(''); + + select.innerHTML = optionsHtml; + select.value = normalizedPreferred; + + if (preferredLanguage !== normalizedPreferred) { + preferredLanguage = normalizedPreferred; + } + } + + const PLATFORM_GROUPS = { + ios: ['ios'], + android: ['android'], + pc: ['windows', 'macos', 'linux'], + tv: ['androidTV', 'appleTV'], + }; + + const PLATFORM_LABEL_KEYS = { + ios: 'platform.ios', + android: 'platform.android', + windows: 'platform.windows', + macos: 'platform.macos', + linux: 'platform.linux', + androidTV: 'platform.android_tv', + appleTV: 'platform.apple_tv', + }; + const LEGAL_DOCUMENT_CONFIG = { public_offer: { icon: '📜', @@ -6332,26 +6483,44 @@ return; } - const { - service_name: rawServiceName = {}, - service_description: rawServiceDescription = {} - } = branding; - function normalizeMap(map) { const normalized = {}; + if (!map) { + return normalized; + } + + if (typeof map === 'string') { + const normalizedValue = normalizeLocalizedValue(map); + if (normalizedValue) { + normalized.default = normalizedValue; + } + return normalized; + } + Object.entries(map || {}).forEach(([lang, value]) => { - if (typeof value !== 'string') { + const normalizedValue = normalizeLocalizedValue(value); + if (!normalizedValue) { return; } - const trimmed = value.trim(); - if (!trimmed) { - return; - } - normalized[lang.toLowerCase()] = trimmed; + normalized[lang.toLowerCase()] = normalizedValue; }); + return normalized; } + function mergeLocaleMaps(...sources) { + const result = {}; + sources.forEach(source => { + const normalized = normalizeMap(source); + Object.entries(normalized).forEach(([lang, value]) => { + if (value) { + result[lang] = value; + } + }); + }); + return result; + } + function applyKey(key, map) { const normalized = normalizeMap(map); if (!Object.keys(normalized).length) { @@ -6388,9 +6557,43 @@ }); } - applyKey('app.name', rawServiceName); - applyKey('app.title', rawServiceName); - applyKey('app.subtitle', rawServiceDescription); + const serviceNameMap = mergeLocaleMaps( + branding.service_name, + branding.name, + branding.title + ); + const serviceDescriptionMap = mergeLocaleMaps( + branding.service_description, + branding.description, + branding.subtitle + ); + + applyKey('app.name', serviceNameMap); + applyKey('app.title', serviceNameMap); + applyKey('app.subtitle', serviceDescriptionMap); + + const logoUrl = normalizeUrl( + branding.logoUrl + || branding.logo_url + || branding.logo + || null + ); + + if (logoUrl) { + const logoIcon = document.querySelector('.logo-icon'); + if (logoIcon) { + logoIcon.classList.add('has-image'); + logoIcon.innerHTML = ''; + const img = document.createElement('img'); + img.src = logoUrl; + const altText = serviceNameMap.default + || serviceNameMap.en + || serviceNameMap.ru + || 'Logo'; + img.alt = altText; + logoIcon.appendChild(img); + } + } } let userData = null; @@ -7019,11 +7222,11 @@ return null; } const normalized = String(lang).toLowerCase(); - if (SUPPORTED_LANGUAGES.includes(normalized)) { + if (supportedLanguages.has(normalized)) { return normalized; } const short = normalized.split('-')[0]; - if (SUPPORTED_LANGUAGES.includes(short)) { + if (supportedLanguages.has(short)) { return short; } return null; @@ -7238,6 +7441,7 @@ } } + renderLanguageSelectOptions(); applyTranslations(); updateConnectButtonLabel(); @@ -7488,6 +7692,50 @@ userData.subscriptionCryptoLink = userData.subscription_crypto_link || null; userData.referral = userData.referral || null; + const happData = payload?.happ; + if (happData && typeof happData === 'object') { + userData.happ = { ...happData }; + + const happCryptoLinkCandidate = happData.cryptoLink + ?? happData.crypto_link + ?? null; + if (happCryptoLinkCandidate) { + if (!userData.happ_crypto_link) { + userData.happ_crypto_link = happCryptoLinkCandidate; + } + if (!userData.happCryptoLink) { + userData.happCryptoLink = happCryptoLinkCandidate; + } + } + + const happRedirectCandidate = happData.cryptolinkRedirectLink + ?? happData.cryptolink_redirect_link + ?? happData.redirectLink + ?? happData.redirect_link + ?? null; + if (happRedirectCandidate) { + if (!userData.happ_cryptolink_redirect_link) { + userData.happ_cryptolink_redirect_link = happRedirectCandidate; + } + if (!userData.happCryptolinkRedirectLink) { + userData.happCryptolinkRedirectLink = happRedirectCandidate; + } + } + + const happLinkCandidate = happData.link + ?? happData.appLink + ?? happData.url + ?? null; + if (happLinkCandidate) { + if (!userData.happ_link) { + userData.happ_link = happLinkCandidate; + } + if (!userData.happLink) { + userData.happLink = happLinkCandidate; + } + } + } + if (hasPaidSubscription()) { subscriptionAutopayState.loading = true; subscriptionAutopayState.saving = false; @@ -7632,9 +7880,13 @@ } const data = await response.json(); - appsConfig = data?.platforms || {}; + appsConfig = sanitizeAppsConfig(data?.platforms || {}); const configData = data?.config || {}; + if (configData.branding || data?.branding) { + applyBrandingOverrides(configData.branding || data.branding); + } + const configUrl = normalizeUrl( configData.subscriptionPurchaseUrl || configData.subscription_purchase_url @@ -7648,6 +7900,45 @@ if (configUrl) { configPurchaseUrl = configUrl; } + + const additionalLocalesRaw = configData.additionalLocales + || configData.additional_locales + || data?.additionalLocales + || data?.additional_locales + || []; + const additionalLocales = (Array.isArray(additionalLocalesRaw) + ? additionalLocalesRaw + : [additionalLocalesRaw]) + .map(locale => typeof locale === 'string' ? locale.trim() : '') + .filter(Boolean); + + const previousLanguage = preferredLanguage; + const languagesUpdated = addSupportedLanguages(additionalLocales); + + if (languagesUpdated && !languageLockedByUser) { + const storedLanguageRaw = safeGetStoredLanguage(); + const storedResolved = resolveLanguage(storedLanguageRaw); + if (storedResolved) { + preferredLanguage = storedResolved; + languageLockedByUser = true; + } else { + const telegramResolved = resolveLanguage(tg.initDataUnsafe?.user?.language_code); + if (telegramResolved) { + preferredLanguage = telegramResolved; + } + } + } + + const normalizedPreferred = resolveLanguage(preferredLanguage) || 'en'; + if (preferredLanguage !== normalizedPreferred) { + preferredLanguage = normalizedPreferred; + } + + renderLanguageSelectOptions(); + + if (languagesUpdated || preferredLanguage !== previousLanguage) { + refreshAfterLanguageChange(); + } } catch (error) { console.warn('Unable to load apps configuration:', error); appsConfig = {}; @@ -8778,20 +9069,25 @@ }); } - function getPlatformKey(platform) { - const mapping = { - ios: 'ios', - android: 'android', - pc: 'windows', - tv: 'androidTV', - mac: 'macos' - }; - return mapping[platform] || platform; + function getPlatformLabel(platformKey) { + const labelKey = PLATFORM_LABEL_KEYS[platformKey]; + if (!labelKey) { + return null; + } + const label = t(labelKey); + return label === labelKey ? platformKey : label; } function getAppsForCurrentPlatform() { - const platformKey = getPlatformKey(currentPlatform); - return appsConfig?.[platformKey] || []; + const platformKeys = PLATFORM_GROUPS[currentPlatform] || [currentPlatform]; + const aggregated = []; + platformKeys.forEach(key => { + const apps = Array.isArray(appsConfig?.[key]) ? appsConfig[key] : []; + apps.forEach(app => { + aggregated.push({ ...app, __platformKey: key }); + }); + }); + return aggregated; } function resolveConnectButtonLabel() { @@ -8803,7 +9099,10 @@ function renderConnectActionButton(appId = '') { const label = escapeHtml(resolveConnectButtonLabel()); - const appAttribute = appId ? ` data-app-id="${escapeHtml(appId)}"` : ''; + const normalizedId = typeof appId === 'string' ? appId.trim() : ''; + const appAttribute = normalizedId + ? ` data-app-id="${escapeHtml(normalizedId)}"` + : ''; return `
@@ -8817,13 +9116,16 @@ `; } - function attachInstructionConnectHandlers(scope) { + function attachInstructionConnectHandlers(scope, apps = null) { if (!scope || typeof scope.querySelectorAll !== 'function') { return; } + + const appList = Array.isArray(apps) ? apps : getAppsForCurrentPlatform(); scope.querySelectorAll('.step-final-button').forEach(button => { button.addEventListener('click', () => { - const link = getConnectLink(); + const appId = button.getAttribute('data-app-id') || null; + const link = getConnectLink(appId, { apps: appList }); if (!link) { return; } @@ -8840,13 +9142,13 @@ } const apps = getAppsForCurrentPlatform(); - const hasConnectLink = Boolean(getConnectLink()); + const hasConnectLink = hasAnyConnectLink(apps); if (!apps.length) { const noDataMessage = `
${escapeHtml(t('apps.no_data'))}
`; const fallbackButton = hasConnectLink ? renderConnectActionButton() : ''; container.innerHTML = `${noDataMessage}${fallbackButton}`; - attachInstructionConnectHandlers(container); + attachInstructionConnectHandlers(container, apps); updateInstructionConnectButtons(hasConnectLink); return; } @@ -8857,13 +9159,19 @@ const featuredBadge = app.isFeatured ? `${escapeHtml(t('apps.featured'))}` : ''; + const platformLabel = app.__platformKey ? getPlatformLabel(app.__platformKey) : null; + const platformBadge = platformLabel + ? `${escapeHtml(platformLabel)}` + : ''; + const badges = [featuredBadge, platformBadge].filter(Boolean).join(''); + const metaSection = badges ? `
${badges}
` : ''; return `
${escapeHtml(iconChar)}
${appName}
- ${featuredBadge} + ${metaSection}
@@ -8873,73 +9181,151 @@ `; }).join(''); - attachInstructionConnectHandlers(container); + attachInstructionConnectHandlers(container, apps); updateInstructionConnectButtons(hasConnectLink); } - function renderAppSteps(app) { - let html = ''; - let stepNum = 1; - - if (app.installationStep) { - const descriptionHtml = app.installationStep.description - ? `
${getLocalizedText(app.installationStep.description)}
` - : ''; - const buttonsHtml = Array.isArray(app.installationStep.buttons) && app.installationStep.buttons.length - ? ` -
- ${app.installationStep.buttons.map(btn => { - const buttonText = escapeHtml(getLocalizedText(btn.buttonText)); - return `${buttonText}`; - }).join('')} -
- ` - : ''; - html += ` -
- ${stepNum++} -
-
${escapeHtml(t('apps.step.download'))}
- ${descriptionHtml} - ${buttonsHtml} -
-
- `; + function renderInstructionStep(step, options = {}) { + if (!step) { + return ''; } - if (app.addSubscriptionStep) { - html += ` -
- ${stepNum++} -
-
${escapeHtml(t('apps.step.add'))}
-
${getLocalizedText(app.addSubscriptionStep.description)}
-
-
- `; + const { + number = null, + defaultTitleKey = '', + defaultTitle = '', + } = options; + + let title = ''; + let explicitTitle = ''; + if (Object.prototype.hasOwnProperty.call(step, 'title')) { + title = getLocalizedText(step.title); + explicitTitle = title; } - if (app.connectAndUseStep) { - html += ` -
- ${stepNum++} -
-
${escapeHtml(t('apps.step.connect'))}
-
${getLocalizedText(app.connectAndUseStep.description)}
-
-
- `; + if (!title) { + if (defaultTitleKey) { + const translated = t(defaultTitleKey); + title = translated && translated !== defaultTitleKey + ? translated + : (defaultTitle || ''); + } else { + title = defaultTitle || ''; + } } - html += renderConnectActionButton(app.id || ''); + const description = Object.prototype.hasOwnProperty.call(step, 'description') + ? getLocalizedText(step.description) + : ''; - return html; + const buttons = Array.isArray(step.buttons) + ? step.buttons.filter(btn => btn && btn.buttonLink) + : []; + + const buttonsHtml = buttons.length + ? ` +
+ ${buttons.map(btn => { + const buttonTextRaw = getLocalizedText(btn.buttonText); + const fallbackButtonLabel = t('apps.button.open_link'); + const resolvedLabel = buttonTextRaw + || (fallbackButtonLabel && fallbackButtonLabel !== 'apps.button.open_link' + ? fallbackButtonLabel + : 'Open link'); + const buttonText = escapeHtml(resolvedLabel); + const buttonHref = escapeHtml(btn.buttonLink); + return `${buttonText}`; + }).join('')} +
+ ` + : ''; + + const hasExplicitTitle = Boolean(explicitTitle); + const hasContent = Boolean(description || buttonsHtml.trim().length || hasExplicitTitle); + if (!hasContent) { + return ''; + } + + const titleHtml = title ? `
${escapeHtml(title)}
` : ''; + const descriptionHtml = description + ? `
${description}
` + : ''; + + const stepNumber = Number.isFinite(number) ? Number(number) : null; + const numberHtml = stepNumber !== null + ? `${stepNumber}` + : ''; + + return ` +
+ ${numberHtml} +
+ ${titleHtml} + ${descriptionHtml} + ${buttonsHtml} +
+
+ `; } - function updateInstructionConnectButtons(hasConnect = Boolean(getConnectLink())) { + function renderAppSteps(app) { + const parts = []; + let stepNum = 1; + + function appendStep(step, defaults) { + const stepHtml = renderInstructionStep(step, { + number: stepNum, + ...(defaults || {}), + }); + if (stepHtml) { + parts.push(stepHtml); + stepNum += 1; + } + } + + appendStep(app.installationStep, { + defaultTitleKey: 'apps.step.download', + defaultTitle: 'Download & install', + }); + + appendStep(app.additionalBeforeAddSubscriptionStep, { + defaultTitleKey: 'apps.step.before_add', + defaultTitle: 'Before adding subscription', + }); + + appendStep(app.addSubscriptionStep, { + defaultTitleKey: 'apps.step.add', + defaultTitle: 'Add subscription', + }); + + appendStep(app.additionalAfterAddSubscriptionStep, { + defaultTitleKey: 'apps.step.after_add', + defaultTitle: 'If the subscription is not added', + }); + + appendStep(app.connectAndUseStep, { + defaultTitleKey: 'apps.step.connect', + defaultTitle: 'Connect & use', + }); + + parts.push(renderConnectActionButton(app.id || '')); + + return parts.join(''); + } + + function updateInstructionConnectButtons(isEnabled = true) { + const apps = getAppsForCurrentPlatform(); + document.querySelectorAll('.step-final-button').forEach(button => { - button.disabled = !hasConnect; + if (!isEnabled) { + button.disabled = true; + return; + } + + const appId = button.getAttribute('data-app-id') || null; + const hasLink = Boolean(getConnectLink(appId, { apps })); + button.disabled = !hasLink; }); } @@ -8951,7 +9337,7 @@ renderApps(); setActivePlatformButton(); - updateInstructionConnectButtons(Boolean(getConnectLink())); + updateInstructionConnectButtons(hasAnyConnectLink()); modal.classList.remove('hidden'); document.body.classList.add('modal-open'); @@ -8976,38 +9362,83 @@ document.body.classList.remove('modal-open'); } + function normalizeLocalizedValue(value) { + if (typeof value !== 'string') { + return ''; + } + + const trimmed = value.trim(); + if (!trimmed || /^[-–—]+$/.test(trimmed)) { + return ''; + } + + return trimmed; + } + function getLocalizedText(textObj) { if (!textObj) { return ''; } if (typeof textObj === 'string') { - return textObj; + return normalizeLocalizedValue(textObj); } const telegramLang = tg.initDataUnsafe?.user?.language_code; + const fallbackLanguages = getSupportedLanguages(); const preferenceOrder = [ preferredLanguage, preferredLanguage?.split('-')[0], userData?.user?.language, telegramLang, telegramLang?.split('-')[0], + ...fallbackLanguages, 'en', 'ru' ].filter(Boolean).map(lang => lang.toLowerCase()); const seen = new Set(); for (const lang of preferenceOrder) { - if (seen.has(lang)) { + const normalizedLang = lang.toLowerCase(); + if (seen.has(normalizedLang)) { continue; } - seen.add(lang); - if (textObj[lang]) { - return textObj[lang]; + seen.add(normalizedLang); + + const candidateKeys = [normalizedLang]; + if (lang !== normalizedLang) { + candidateKeys.push(lang); + } + + for (const key of candidateKeys) { + if (!Object.prototype.hasOwnProperty.call(textObj, key)) { + continue; + } + const value = normalizeLocalizedValue(textObj[key]); + if (value) { + return value; + } } } - const fallback = Object.values(textObj).find(value => typeof value === 'string' && value.trim().length); - return fallback || ''; + const fallbackKeys = ['default', 'en', 'ru']; + for (const key of fallbackKeys) { + if (!Object.prototype.hasOwnProperty.call(textObj, key)) { + continue; + } + const value = normalizeLocalizedValue(textObj[key]); + if (value) { + return value; + } + } + + for (const value of Object.values(textObj)) { + const normalizedValue = normalizeLocalizedValue(value); + if (normalizedValue) { + return normalizedValue; + } + } + + return ''; } function ensureArray(value) { @@ -9060,6 +9491,186 @@ return fallback; } + function sanitizeAppsConfig(rawPlatforms) { + const sanitized = {}; + if (!rawPlatforms || typeof rawPlatforms !== 'object') { + return sanitized; + } + + Object.entries(rawPlatforms).forEach(([platformKey, apps]) => { + if (!Array.isArray(apps)) { + sanitized[platformKey] = []; + return; + } + + const normalizedApps = apps + .map(app => sanitizeAppDefinition(app)) + .filter(Boolean); + sanitized[platformKey] = normalizedApps; + }); + + return sanitized; + } + + function sanitizeAppDefinition(app) { + if (!app || typeof app !== 'object') { + return null; + } + + const sanitized = {}; + + const rawId = app.id ?? app.appId ?? app.slug ?? null; + let normalizedId = typeof rawId === 'string' && rawId.trim().length + ? rawId.trim() + : null; + + let resolvedName = ''; + if (typeof app.name === 'string') { + resolvedName = normalizeLocalizedValue(app.name) || app.name; + } else if (app.name && typeof app.name === 'object') { + const nameCandidates = [ + app.name.en, + app.name.default, + app.name.ru, + ...Object.values(app.name) + ]; + for (const candidate of nameCandidates) { + const normalizedCandidate = normalizeLocalizedValue(candidate); + if (normalizedCandidate) { + resolvedName = normalizedCandidate; + break; + } + } + } + + if (!resolvedName && typeof rawId === 'string') { + resolvedName = rawId.trim(); + } + + if (!normalizedId && resolvedName) { + const slug = resolvedName + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + normalizedId = slug || null; + } + + sanitized.id = normalizedId; + sanitized.name = resolvedName || 'App'; + + const rawScheme = app.urlScheme ?? app.url_scheme ?? ''; + sanitized.urlScheme = typeof rawScheme === 'string' && rawScheme.trim().length + ? rawScheme.trim() + : null; + + sanitized.isFeatured = coerceBoolean(app.isFeatured ?? app.is_featured, false); + sanitized.isNeedBase64Encoding = coerceBoolean( + app.isNeedBase64Encoding ?? app.is_need_base64_encoding, + false + ); + + const installationStep = sanitizeStep(app.installationStep ?? app.installation_step); + if (installationStep) { + sanitized.installationStep = installationStep; + } + + const beforeAddStep = sanitizeStep( + app.additionalBeforeAddSubscriptionStep + ?? app.additional_before_add_subscription_step + ); + if (beforeAddStep) { + sanitized.additionalBeforeAddSubscriptionStep = beforeAddStep; + } + + const addStep = sanitizeStep(app.addSubscriptionStep ?? app.add_subscription_step); + if (addStep) { + sanitized.addSubscriptionStep = addStep; + } + + const afterAddStep = sanitizeStep( + app.additionalAfterAddSubscriptionStep + ?? app.additional_after_add_subscription_step + ); + if (afterAddStep) { + sanitized.additionalAfterAddSubscriptionStep = afterAddStep; + } + + const connectStep = sanitizeStep(app.connectAndUseStep ?? app.connect_and_use_step); + if (connectStep) { + sanitized.connectAndUseStep = connectStep; + } + + return sanitized; + } + + function sanitizeStep(step) { + if (!step || typeof step !== 'object') { + return null; + } + + const sanitized = {}; + + if (Object.prototype.hasOwnProperty.call(step, 'title')) { + sanitized.title = step.title; + } + + if (Object.prototype.hasOwnProperty.call(step, 'description')) { + sanitized.description = step.description; + } + + const rawButtons = ensureArray(step.buttons ?? step.buttonList ?? step.button_list); + const buttons = rawButtons.map(sanitizeButton).filter(Boolean); + if (buttons.length) { + sanitized.buttons = buttons; + } + + if ( + !Object.prototype.hasOwnProperty.call(sanitized, 'title') + && !Object.prototype.hasOwnProperty.call(sanitized, 'description') + && !sanitized.buttons + ) { + return null; + } + + return sanitized; + } + + function sanitizeButton(button) { + if (!button || typeof button !== 'object') { + return null; + } + + const link = normalizeUrl( + button.buttonLink + ?? button.button_link + ?? button.link + ?? button.url + ?? button.href + ); + + if (!link) { + return null; + } + + const rawText = button.buttonText + ?? button.button_text + ?? button.text + ?? button.label + ?? ''; + + if (typeof rawText === 'string') { + return { + buttonLink: link, + buttonText: normalizeLocalizedValue(rawText) || rawText, + }; + } + + return { + buttonLink: link, + buttonText: rawText, + }; + } + async function parseJsonSafe(response) { try { return await response.json(); @@ -16888,63 +17499,158 @@ return userData?.subscription_url || userData?.subscriptionUrl || ''; } - function getHappCryptoLink() { - if (!userData) { + function getHappCryptoLink(data = userData) { + if (!data || typeof data !== 'object') { return null; } - return ( - userData.happ_crypto_link || - userData.subscriptionCryptoLink || - userData.subscription_crypto_link || - null - ); + const happData = data.happ && typeof data.happ === 'object' + ? data.happ + : null; + + const candidates = [ + data.happ_crypto_link, + data.happCryptoLink, + happData?.cryptoLink, + happData?.crypto_link, + happData?.crypto?.link, + happData?.links?.crypto, + data.subscriptionCryptoLink, + data.subscription_crypto_link, + ]; + + for (const candidate of candidates) { + const normalized = normalizeUrl(candidate); + if (normalized) { + return normalized; + } + } + + return null; } - function getConnectLink() { + function encodeToBase64(value) { + if (typeof value !== 'string') { + return ''; + } + + try { + return btoa(value); + } catch (error) { + try { + return btoa(encodeURIComponent(value).replace(/%([0-9A-F]{2})/g, (_, p1) => String.fromCharCode(parseInt(p1, 16)))); + } catch (nestedError) { + console.warn('Failed to base64 encode value:', nestedError); + return value; + } + } + } + + function buildAppSchemeLink(app, subscriptionUrl) { + if (!app || !subscriptionUrl) { + return null; + } + + const scheme = typeof app.urlScheme === 'string' ? app.urlScheme.trim() : ''; + if (!scheme) { + return null; + } + + let payload = subscriptionUrl; + if (app.isNeedBase64Encoding) { + const encoded = encodeToBase64(subscriptionUrl); + payload = encoded || subscriptionUrl; + } + + return `${scheme}${payload}`; + } + + function getConnectLink(appId = null, options = {}) { if (!userData) { return null; } + const apps = Array.isArray(options.apps) + ? options.apps + : getAppsForCurrentPlatform(); + const normalizedAppId = typeof appId === 'string' && appId.trim().length + ? appId.trim() + : null; + const requestedApp = normalizedAppId + ? apps.find(app => (app.id || '').toString() === normalizedAppId) + : null; + const featuredApp = apps.find(app => coerceBoolean(app.isFeatured, false)) || apps[0] || null; + const selectedApp = requestedApp || featuredApp || null; + + const subscriptionUrl = normalizeUrl(getCurrentSubscriptionUrl()); const happCryptoLink = getHappCryptoLink(); - const isPC = currentPlatform === 'pc' || (!['ios', 'android', 'tv'].includes(currentPlatform)); - - // For PC, prefer direct subscription URL if no Happ redirect link - if (isPC) { - if (userData.happ_cryptolink_redirect_link) { - return userData.happ_cryptolink_redirect_link; - } + const happData = userData?.happ && typeof userData.happ === 'object' + ? userData.happ + : null; + const redirectLink = normalizeUrl( + userData?.happ_cryptolink_redirect_link + || userData?.happCryptolinkRedirectLink + || happData?.cryptolinkRedirectLink + || happData?.cryptolink_redirect_link + || happData?.redirectLink + || happData?.redirect_link + || null + ); + const happLink = normalizeUrl( + userData?.happ_link + || userData?.happLink + || happData?.link + || happData?.appLink + || happData?.url + || null + ); + + const schemeLink = selectedApp && subscriptionUrl + ? buildAppSchemeLink(selectedApp, subscriptionUrl) + : null; + + if (selectedApp && (selectedApp.id === 'happ' || selectedApp.id === 'happ-app')) { if (happCryptoLink) { return happCryptoLink; } - // Return plain subscription URL for PC - return getCurrentSubscriptionUrl(); + if (redirectLink) { + return redirectLink; + } + if (happLink) { + return happLink; + } + return subscriptionUrl || null; } - - // Original logic for mobile platforms + + if (schemeLink) { + return schemeLink; + } + + if (redirectLink) { + return redirectLink; + } + if (happCryptoLink) { return happCryptoLink; } - if (userData.happ_cryptolink_redirect_link) { - return userData.happ_cryptolink_redirect_link; + if (happLink) { + return happLink; } - const subscriptionUrl = getCurrentSubscriptionUrl(); - if (!subscriptionUrl) { - return null; + return subscriptionUrl || null; + } + + function hasAnyConnectLink(precomputedApps = null) { + const apps = Array.isArray(precomputedApps) + ? precomputedApps + : getAppsForCurrentPlatform(); + + if (Boolean(getConnectLink(null, { apps }))) { + return true; } - const apps = getAppsForCurrentPlatform(); - const featuredApp = apps.find(app => app.isFeatured) || apps[0]; - - if (featuredApp?.urlScheme) { - return `${featuredApp.urlScheme}${subscriptionUrl}`; - } - if (userData?.happ_link && featuredApp?.id === 'happ') { - return userData.happ_link; - } - return subscriptionUrl; + return apps.some(app => Boolean(getConnectLink(app.id || null, { apps }))); } function openExternalLink(link, options = {}) { @@ -17188,6 +17894,7 @@ const guideBtn = document.getElementById('openGuideBtn'); const connectLink = getConnectLink(); + const anyConnectLink = hasAnyConnectLink(); const showConnectButton = hasActiveSubscription(); if (guideBtn) { @@ -17199,7 +17906,7 @@ closeInstallationModal(); } - updateInstructionConnectButtons(showConnectButton && Boolean(connectLink)); + updateInstructionConnectButtons(showConnectButton && anyConnectLink); const subscriptionUrl = getCurrentSubscriptionUrl(); if (copyBtn) {