From 79afe3a733167c6a64627aff70290a5f1815c6c2 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Thu, 5 Feb 2026 08:29:54 +0300 Subject: [PATCH] feat: render original RemnaWave blocks on connection page - Add RemnaWave types (RemnawaveAppClient, RemnawaveBlockClient, etc.) - Dual rendering: RemnaWave blocks with SVG icons or legacy steps - Platform dropdown, DOMPurify for SVG sanitization - Button types: subscriptionLink, copyButton, external --- src/pages/Connection.tsx | 545 ++++++++++++++++++++++++++++++++++++--- src/types/index.ts | 39 ++- 2 files changed, 543 insertions(+), 41 deletions(-) diff --git a/src/pages/Connection.tsx b/src/pages/Connection.tsx index 446df9f..e23991e 100644 --- a/src/pages/Connection.tsx +++ b/src/pages/Connection.tsx @@ -3,12 +3,19 @@ import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { openLink as sdkOpenLink } from '@telegram-apps/sdk-react'; +import DOMPurify from 'dompurify'; import { subscriptionApi } from '../api/subscription'; import { useTelegramWebApp } from '../hooks/useTelegramWebApp'; import { useBackButton, useHaptic } from '@/platform'; import { resolveTemplate, hasTemplates } from '../utils/templateEngine'; import { useAuthStore } from '../store/auth'; -import type { AppInfo, AppConfig, LocalizedText } from '../types'; +import type { + AppInfo, + AppConfig, + LocalizedText, + RemnawaveAppClient, + RemnawavePlatformData, +} from '../types'; // Icons const CopyIcon = () => ( @@ -130,14 +137,38 @@ function detectPlatform(): string | null { return null; } +// Type guards for platform data +function isRemnawavePlatform( + data: AppInfo[] | RemnawavePlatformData, +): data is RemnawavePlatformData { + return data && !Array.isArray(data) && 'apps' in data; +} + +function getPlatformAppsCount(data: AppInfo[] | RemnawavePlatformData): number { + if (isRemnawavePlatform(data)) return data.apps.length; + return Array.isArray(data) ? data.length : 0; +} + +function getRemnawaveApps(data: AppInfo[] | RemnawavePlatformData): RemnawaveAppClient[] { + if (isRemnawavePlatform(data)) return data.apps; + return []; +} + +function getClassicApps(data: AppInfo[] | RemnawavePlatformData): AppInfo[] { + if (Array.isArray(data)) return data; + return []; +} + export default function Connection() { const { t, i18n } = useTranslation(); const navigate = useNavigate(); const { user } = useAuthStore(); const [selectedApp, setSelectedApp] = useState(null); + const [selectedRemnawaveApp, setSelectedRemnawaveApp] = useState(null); const [copied, setCopied] = useState(false); const [showAppSelector, setShowAppSelector] = useState(false); const [selectedPlatform, setSelectedPlatform] = useState(null); + const [activePlatformKey, setActivePlatformKey] = useState(null); const { isTelegramWebApp } = useTelegramWebApp(); const { impact: hapticImpact } = useHaptic(); @@ -158,19 +189,41 @@ export default function Connection() { queryFn: () => subscriptionApi.getAppConfig(), }); + const isRemnawave = appConfig?.isRemnawave === true; const detectedPlatform = useMemo(() => detectPlatform(), []); + // Auto-select app on load (classic format) useEffect(() => { - if (!appConfig?.platforms || selectedApp) return; + if (!appConfig?.platforms || isRemnawave || selectedApp) return; let platform = detectedPlatform; - if (!platform || !appConfig.platforms[platform]?.length) { - platform = platformOrder.find((p) => appConfig.platforms[p]?.length > 0) || null; + if (!platform || !getPlatformAppsCount(appConfig.platforms[platform] ?? [])) { + platform = + platformOrder.find((p) => getPlatformAppsCount(appConfig.platforms[p] ?? []) > 0) || null; } - if (!platform || !appConfig.platforms[platform]?.length) return; - const apps = appConfig.platforms[platform]; + if (!platform) return; + const apps = getClassicApps(appConfig.platforms[platform] ?? []); + if (!apps.length) return; const app = apps.find((a) => a.isFeatured) || apps[0]; if (app) setSelectedApp(app); - }, [appConfig, detectedPlatform, selectedApp]); + }, [appConfig, detectedPlatform, selectedApp, isRemnawave]); + + // Auto-select app on load (RemnaWave format) + useEffect(() => { + if (!appConfig?.platforms || !isRemnawave || selectedRemnawaveApp) return; + let platform = detectedPlatform; + if (!platform || !getPlatformAppsCount(appConfig.platforms[platform] ?? [])) { + platform = + platformOrder.find((p) => getPlatformAppsCount(appConfig.platforms[p] ?? []) > 0) || null; + } + if (!platform) return; + const apps = getRemnawaveApps(appConfig.platforms[platform] ?? []); + if (!apps.length) return; + const app = apps.find((a) => a.featured) || apps[0]; + if (app) { + setSelectedRemnawaveApp(app); + setActivePlatformKey(platform); + } + }, [appConfig, detectedPlatform, selectedRemnawaveApp, isRemnawave]); const handleGoBack = useCallback(() => { navigate(-1); @@ -217,7 +270,9 @@ export default function Connection() { const availablePlatforms = useMemo(() => { if (!appConfig?.platforms) return []; - const available = platformOrder.filter((key) => appConfig.platforms[key]?.length > 0); + const available = platformOrder.filter( + (key) => getPlatformAppsCount(appConfig.platforms[key] ?? []) > 0, + ); if (detectedPlatform && available.includes(detectedPlatform)) { return [detectedPlatform, ...available.filter((p) => p !== detectedPlatform)]; } @@ -254,36 +309,39 @@ export default function Connection() { } }; + const openDeepLink = useCallback( + (deepLink: string) => { + let resolved = deepLink; + if (hasTemplates(resolved)) { + resolved = resolveUrl(resolved); + } + + const isMobilePlatform = detectedPlatform === 'ios' || detectedPlatform === 'android'; + if (isMobilePlatform) { + window.location.href = resolved; + return; + } + + const redirectUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(resolved)}&lang=${i18n.language || 'en'}`; + try { + sdkOpenLink(redirectUrl, { tryInstantView: false }); + return; + } catch { + // SDK not available, fallback + } + window.location.href = redirectUrl; + }, + [detectedPlatform, i18n.language, resolveUrl], + ); + const handleConnect = (app: AppInfo) => { if (!app.deepLink || !isValidDeepLink(app.deepLink)) return; - let deepLink = app.deepLink; + openDeepLink(app.deepLink); + }; - // Resolve templates if present - if (hasTemplates(deepLink)) { - deepLink = resolveUrl(deepLink); - } - - // On mobile (iOS/Android) apps handle URL schemes natively — - // open the deep link directly without the redirect.html intermediary. - const isMobilePlatform = detectedPlatform === 'ios' || detectedPlatform === 'android'; - if (isMobilePlatform) { - window.location.href = deepLink; - return; - } - - // Desktop platforms (macOS, Windows, Linux) and others 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. - const redirectUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(deepLink)}&lang=${i18n.language || 'en'}`; - - try { - sdkOpenLink(redirectUrl, { tryInstantView: false }); - return; - } catch { - // SDK not available, fallback - } - window.location.href = redirectUrl; + const handleConnectRemnawave = (app: RemnawaveAppClient) => { + if (!app.deepLink || !isValidDeepLink(app.deepLink)) return; + openDeepLink(app.deepLink); }; // Resolve button links for step buttons @@ -291,6 +349,14 @@ export default function Connection() { return resolveUrl(link); }; + // Get sanitized SVG HTML + const getSvgHtml = (svgKey: string | undefined): string => { + if (!svgKey || !appConfig?.svgLibrary?.[svgKey]?.svgString) return ''; + return DOMPurify.sanitize(appConfig.svgLibrary[svgKey].svgString, { + USE_PROFILES: { svg: true, svgFilters: true }, + }); + }; + // Loading if (isLoading) { return ( @@ -327,7 +393,403 @@ export default function Connection() { ); } - // App selector + // ============================================= + // RemnaWave format rendering + // ============================================= + if (isRemnawave) { + const currentApp = selectedRemnawaveApp; + + // Get platform display name from config or fallback + const getPlatformDisplayName = (key: string): string => { + 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; + }; + + // App selector for RemnaWave + if (showAppSelector) { + if (!selectedPlatform) { + // Platform selection + const platformIcons: Record = { + ios: ( + + + + ), + android: ( + + + + ), + windows: ( + + + + ), + macos: ( + + + + ), + linux: ( + + + + ), + androidTV: ( + + + + ), + appleTV: ( + + + + ), + }; + + return ( +
+
+ {!isTelegramWebApp && ( + + )} +

+ {t('subscription.connection.selectPlatform')} +

+
+
+ {availablePlatforms.map((platform) => { + const platformData = appConfig.platforms[platform]; + if (!platformData) return null; + const appCount = getPlatformAppsCount(platformData); + if (!appCount) return null; + const isCurrentPlatform = platform === detectedPlatform; + + return ( + + ); + })} +
+
+ ); + } + + // App selection for chosen platform (RemnaWave) + const platformData = appConfig.platforms[selectedPlatform]; + const apps = platformData ? getRemnawaveApps(platformData) : []; + const isCurrentPlatform = selectedPlatform === detectedPlatform; + + return ( +
+
+ {!isTelegramWebApp && ( + + )} +
+

+ {getPlatformDisplayName(selectedPlatform)} +

+ {isCurrentPlatform && ( + + {t('subscription.connection.yourDevice')} + + )} +
+
+
+
+ {apps.map((app, idx) => { + const isSelected = selectedRemnawaveApp?.name === app.name; + return ( + + ); + })} +
+
+
+ ); + } + + // Main RemnaWave view — blocks rendering + return ( +
+ {/* Header */} +
+
+ {!isTelegramWebApp && ( + + )} +

+ {t('subscription.connection.title')} +

+ {/* Platform dropdown */} + {availablePlatforms.length > 1 && ( + + )} +
+ + {/* App selector button */} + +
+ + {/* Blocks */} + {currentApp && ( +
+ {currentApp.blocks.map((block, blockIdx) => { + const svgHtml = getSvgHtml(block.svgIconKey); + const iconColor = block.svgIconColor || '#8B5CF6'; + + return ( +
+
+ {/* SVG icon */} + {svgHtml && ( +
+
+
+ )} +
+

{getLocalizedText(block.title)}

+

+ {getLocalizedText(block.description)} +

+ {/* Buttons */} + {block.buttons && block.buttons.length > 0 && ( +
+ {block.buttons.map((btn, btnIdx) => { + const btnText = getLocalizedText(btn.text); + + if (btn.type === 'subscriptionLink') { + return ( + + ); + } + + if (btn.type === 'copyButton') { + return ( + + ); + } + + // external link + const href = btn.resolvedUrl || btn.url || btn.link || ''; + if (!isValidExternalUrl(href)) return null; + + // Render SVG icon for button if available + const btnSvgHtml = getSvgHtml(btn.svgIconKey); + + return ( + + {btnSvgHtml ? ( + + )} +
+
+
+ ); + })} +
+ )} +
+ ); + } + + // ============================================= + // Classic format rendering (fallback) + // ============================================= + + // App selector (classic) if (showAppSelector) { const platformNames: Record = { ios: 'iOS', @@ -396,8 +858,10 @@ export default function Connection() {
{availablePlatforms.map((platform) => { - const apps = appConfig.platforms[platform]; - if (!apps?.length) return null; + const platformData = appConfig.platforms[platform]; + if (!platformData) return null; + const apps = getClassicApps(platformData); + if (!apps.length) return null; const isCurrentPlatform = platform === detectedPlatform; const appCount = apps.length; @@ -450,7 +914,8 @@ export default function Connection() { } // Step 2: App selection for chosen platform - const apps = appConfig.platforms[selectedPlatform] || []; + const platformData = appConfig.platforms[selectedPlatform]; + const apps = platformData ? getClassicApps(platformData) : []; const isCurrentPlatform = selectedPlatform === detectedPlatform; return ( @@ -536,7 +1001,7 @@ export default function Connection() { ); } - // Main view + // Main view (classic) return (
diff --git a/src/types/index.ts b/src/types/index.ts index 0d9e1ff..bed39f7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -502,8 +502,36 @@ export interface AppInfo { additionalAfterAddSubscriptionStep?: AppStep; } +// RemnaWave original format types +export interface RemnawaveButtonClient { + url?: string; + link?: string; + text: LocalizedText; + type?: 'external' | 'subscriptionLink' | 'copyButton'; + svgIconKey?: string; + resolvedUrl?: string; +} + +export interface RemnawaveBlockClient { + title: LocalizedText; + description: LocalizedText; + buttons?: RemnawaveButtonClient[]; + svgIconKey?: string; + svgIconColor?: string; +} + +export interface RemnawaveAppClient { + name: string; + featured?: boolean; + deepLink?: string | null; + blocks: RemnawaveBlockClient[]; +} + +export interface RemnawavePlatformData { + apps: RemnawaveAppClient[]; +} + export interface AppConfig { - platforms: Record; platformNames: Record; hasSubscription: boolean; subscriptionUrl: string | null; @@ -513,6 +541,15 @@ export interface AppConfig { logoUrl?: string; supportUrl?: string; }; + + // RemnaWave format (isRemnawave: true) + isRemnawave?: boolean; + svgLibrary?: Record; + baseTranslations?: Record; + baseSettings?: { isShowTutorialButton: boolean; tutorialUrl: string }; + + // Platform data — either classic AppInfo[] or RemnaWave { apps: RemnawaveAppClient[] } + platforms: Record; } // Pending payment types