import { useState, useMemo, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import DOMPurify from 'dompurify'; import type { AppConfig, LocalizedText, RemnawaveAppClient, RemnawavePlatformData, RemnawaveButtonClient, } from '@/types'; import { useTheme } from '@/hooks/useTheme'; import { CardsBlock, TimelineBlock, AccordionBlock, MinimalBlock, BlockButtons } from './blocks'; import type { BlockRendererProps, RenderBlock } from './blocks'; import TvQuickConnect from './TvQuickConnect'; import { BackIcon, BookOpenIcon, ChevronIcon } from '@/components/icons'; const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']; function detectPlatform(): string | null { if (typeof window === 'undefined' || !navigator?.userAgent) return null; const ua = navigator.userAgent.toLowerCase(); if (/iphone|ipad|ipod/.test(ua)) return 'ios'; if (/android/.test(ua)) return /tv|television/.test(ua) ? 'androidTV' : 'android'; if (/macintosh|mac os x/.test(ua)) return 'macos'; if (/windows/.test(ua)) return 'windows'; if (/linux/.test(ua)) return 'linux'; return null; } const RENDERERS: Record> = { cards: CardsBlock, timeline: TimelineBlock, accordion: AccordionBlock, minimal: MinimalBlock, }; /** TV quick-connect is a Happ-only feature (check.happ.su/sendtv) — show it only * for the Happ app, detected by its happ:// deep-link scheme (name as fallback). */ function isHappApp(app: RemnawaveAppClient | null): boolean { if (!app) return false; if ((app.deepLink ?? '').toLowerCase().startsWith('happ://')) return true; return app.name.toLowerCase().includes('happ'); } interface Props { appConfig: AppConfig; onOpenDeepLink: (url: string) => void; isTelegramWebApp: boolean; onGoBack: () => void; onOpenQR?: () => void; } export default function InstallationGuide({ appConfig, onOpenDeepLink, isTelegramWebApp, onGoBack, onOpenQR, }: Props) { const { t, i18n } = useTranslation(); const { isLight } = useTheme(); const detectedPlatform = useMemo(() => detectPlatform(), []); const isMobile = typeof window !== 'undefined' && window.innerWidth < 768; const [activePlatformKey, setActivePlatformKey] = useState(null); const [selectedApp, setSelectedApp] = useState(null); const getLocalizedText = useCallback( (text: LocalizedText | undefined): string => { if (!text) return ''; const lang = i18n.language || 'en'; return text[lang] || text['en'] || text['ru'] || Object.values(text)[0] || ''; }, [i18n.language], ); const getBaseTranslation = useCallback( (key: string, i18nKey: string): string => { const bt = appConfig.baseTranslations; if (bt && key in bt) { const text = getLocalizedText(bt[key as keyof typeof bt] as LocalizedText); if (text) return text; } return t(i18nKey); }, [appConfig.baseTranslations, getLocalizedText, t], ); const getSvgHtml = useCallback( (svgKey: string | undefined): string => { if (!svgKey || !appConfig.svgLibrary?.[svgKey]) return ''; const entry = appConfig.svgLibrary[svgKey]; const raw = typeof entry === 'string' ? entry : entry.svgString; if (!raw) return ''; return DOMPurify.sanitize(raw, { USE_PROFILES: { svg: true, svgFilters: true } }); }, [appConfig.svgLibrary], ); const availablePlatforms = useMemo(() => { if (!appConfig.platforms) return []; const available = platformOrder.filter((key) => { const data = appConfig.platforms[key] as RemnawavePlatformData | undefined; return data && data.apps && data.apps.length > 0; }); if (detectedPlatform && available.includes(detectedPlatform)) { return [detectedPlatform, ...available.filter((p) => p !== detectedPlatform)]; } return available; }, [appConfig.platforms, detectedPlatform]); useEffect(() => { if (selectedApp || !availablePlatforms.length) return; const platform = availablePlatforms[0]; const data = appConfig.platforms[platform] as RemnawavePlatformData | undefined; if (!data?.apps?.length) return; const app = data.apps.find((a) => a.featured) || data.apps[0]; if (app) { setSelectedApp(app); setActivePlatformKey(platform); } }, [appConfig.platforms, availablePlatforms, selectedApp]); const renderBlockButtons = useCallback( (buttons: RemnawaveButtonClient[] | undefined, variant: 'light' | 'subtle') => ( ), [ appConfig.subscriptionUrl, appConfig.hideLink, selectedApp?.deepLink, isLight, getLocalizedText, getBaseTranslation, getSvgHtml, onOpenDeepLink, ], ); const userIsOnTv = detectedPlatform === 'androidTV' || detectedPlatform === 'appleTV'; // Happ's TV quick-connect (check.happ.su/sendtv, 5-digit code) is an ANDROID TV // -only API. Apple TV uses a different mechanism (tv.happ.su temporary code), so // the block must NOT show there — it would POST to the wrong endpoint. const isAndroidTvLayout = (activePlatformKey || availablePlatforms[0]) === 'androidTV' && !userIsOnTv; const currentPlatformKey = activePlatformKey || availablePlatforms[0]; const currentPlatformData = currentPlatformKey ? (appConfig.platforms[currentPlatformKey] as RemnawavePlatformData | undefined) : undefined; const currentPlatformApps = currentPlatformData?.apps || []; // Platform display name const getPlatformDisplayName = useCallback( (key: string): string => { const data = appConfig.platforms[key] as RemnawavePlatformData | undefined; if (data?.displayName) { const name = getLocalizedText(data.displayName); if (name) return name; } if (appConfig.platformNames?.[key]) { return getLocalizedText(appConfig.platformNames[key]); } const fallback: Record = { ios: 'iOS', android: 'Android', windows: 'Windows', macos: 'macOS', linux: 'Linux', androidTV: 'Android TV', appleTV: 'Apple TV', }; return fallback[key] || key; }, [appConfig.platforms, appConfig.platformNames, getLocalizedText], ); // Platform SVG icon for dropdown const currentPlatformSvg = getSvgHtml(currentPlatformData?.svgIconKey); // Block renderer const blockType = appConfig.uiConfig?.installationGuidesBlockType || 'cards'; const Renderer = RENDERERS[blockType] || CardsBlock; // For the Happ Android TV app, inject the TV connect widget into a step as // customNode so it renders THROUGH the active block style (cards/timeline/ // accordion/minimal) instead of as separate clashing cards that break it. const showTvConnect = Boolean( selectedApp && isAndroidTvLayout && isHappApp(selectedApp) && appConfig.subscriptionUrl, ); let renderBlocks: RenderBlock[] = selectedApp?.blocks ?? []; if (selectedApp && showTvConnect && appConfig.subscriptionUrl) { // install → add-subscription → connect: attach to the add step (index 1); // fall back to the last block for shorter configs. const idx = selectedApp.blocks.length >= 3 ? 1 : Math.max(0, selectedApp.blocks.length - 1); const widget = ; renderBlocks = selectedApp.blocks.map((b, i) => (i === idx ? { ...b, customNode: widget } : b)); } return (
{/* Header + platform dropdown */}
{!isTelegramWebApp && ( )}

{getBaseTranslation('installationGuideHeader', 'subscription.connection.title')}

{appConfig.subscriptionUrl && onOpenQR && ( )} {availablePlatforms.length > 1 && (
{currentPlatformSvg && (
)}
)}
{/* App chips */} {currentPlatformApps.length > 0 && (
{currentPlatformApps.map((app, idx) => { const isSelected = selectedApp?.name === app.name; const appIconSvg = getSvgHtml(app.svgIconKey); return ( ); })}
)} {/* Tutorial button */} {appConfig.baseSettings?.isShowTutorialButton && appConfig.baseSettings?.tutorialUrl && ( {getBaseTranslation('tutorial', 'subscription.connection.tutorial')} )} {/* Blocks rendered in the panel's active style. For the Happ Android TV app the TV connect widget is injected into a step (customNode), so it adapts to that style instead of breaking it. */} {selectedApp && ( )}
); }