diff --git a/src/components/connection/InstallationGuide.tsx b/src/components/connection/InstallationGuide.tsx new file mode 100644 index 0000000..a3e5610 --- /dev/null +++ b/src/components/connection/InstallationGuide.tsx @@ -0,0 +1,438 @@ +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 { CardsBlock, TimelineBlock, AccordionBlock, MinimalBlock } from './blocks'; +import type { BlockRendererProps } from './blocks'; + +const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']; + +// eslint-disable-next-line no-script-url +const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']; + +function isValidDeepLink(url: string | undefined): boolean { + if (!url) return false; + const lowerUrl = url.toLowerCase().trim(); + if (dangerousSchemes.some((s) => lowerUrl.startsWith(s))) return false; + return lowerUrl.includes('://'); +} + +function isValidExternalUrl(url: string | undefined): boolean { + if (!url) return false; + const lowerUrl = url.toLowerCase().trim(); + if (dangerousSchemes.some((s) => lowerUrl.startsWith(s))) return false; + return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://'); +} + +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, +}; + +// Icons +const CopyIcon = () => ( + + + +); + +const CheckIcon = () => ( + + + +); + +const BackIcon = () => ( + + + +); + +interface Props { + appConfig: AppConfig; + onOpenDeepLink: (url: string) => void; + isTelegramWebApp: boolean; + onGoBack: () => void; +} + +export default function InstallationGuide({ + appConfig, + onOpenDeepLink, + isTelegramWebApp, + onGoBack, +}: Props) { + const { t, i18n } = useTranslation(); + + const detectedPlatform = useMemo(() => detectPlatform(), []); + const isMobile = typeof window !== 'undefined' && window.innerWidth < 768; + + const [activePlatformKey, setActivePlatformKey] = useState(null); + const [selectedApp, setSelectedApp] = useState(null); + const [copied, setCopied] = useState(false); + + // --- Helpers --- + + 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], + ); + + // --- Available platforms --- + + 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]); + + // --- Auto-select platform & app --- + + 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]); + + // --- Copy --- + + const handleCopy = useCallback(async (url: string) => { + try { + await navigator.clipboard.writeText(url); + } catch { + const textarea = document.createElement('textarea'); + textarea.value = url; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + } + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, []); + + // --- Button renderer (passed to block renderers) --- + + const renderBlockButtons = useCallback( + (buttons: RemnawaveButtonClient[] | undefined, variant: 'light' | 'subtle') => { + if (!buttons || buttons.length === 0) return null; + + const baseClass = + variant === 'light' + ? 'rounded-xl border border-accent-500/40 px-4 py-2 text-sm font-medium text-accent-400 transition-all hover:bg-accent-500/10' + : 'rounded-xl px-3 py-1.5 text-sm font-medium text-dark-300 transition-all hover:bg-dark-700/50'; + + return ( +
+ {buttons.map((btn, idx) => { + const btnText = getLocalizedText(btn.text); + const btnSvg = getSvgHtml(btn.svgIconKey); + const btnIcon = btnSvg ? ( +
+ ) : null; + + if (btn.type === 'subscriptionLink') { + const url = + btn.resolvedUrl || + btn.url || + btn.link || + selectedApp?.deepLink || + appConfig.subscriptionUrl; + if (!url || !isValidDeepLink(url)) return null; + return ( + + ); + } + + if (btn.type === 'copyButton') { + if (appConfig.hideLink) return null; + const url = btn.resolvedUrl || appConfig.subscriptionUrl; + if (!url) return null; + return ( + + ); + } + + // external + const href = btn.link || btn.url || ''; + if (!isValidExternalUrl(href)) return null; + return ( + + {btnIcon} + {btnText} + + ); + })} +
+ ); + }, + [ + selectedApp, + appConfig, + copied, + getSvgHtml, + getLocalizedText, + getBaseTranslation, + handleCopy, + onOpenDeepLink, + t, + ], + ); + + // --- Current platform data --- + + 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; + + return ( +
+ {/* Header + platform dropdown */} +
+ {!isTelegramWebApp && ( + + )} +

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

+ {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 */} + {selectedApp && ( + + )} +
+ ); +} diff --git a/src/components/connection/blocks/AccordionBlock.tsx b/src/components/connection/blocks/AccordionBlock.tsx new file mode 100644 index 0000000..c1d75e2 --- /dev/null +++ b/src/components/connection/blocks/AccordionBlock.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react'; +import { getColorGradient } from '@/utils/colorParser'; +import { ThemeIcon } from './ThemeIcon'; +import type { BlockRendererProps } from './types'; + +export function AccordionBlock({ + blocks, + isMobile, + getLocalizedText, + getSvgHtml, + renderBlockButtons, +}: BlockRendererProps) { + const [openIndex, setOpenIndex] = useState(0); + + return ( +
+ {blocks.map((block, index) => { + const gradientStyle = getColorGradient(block.svgIconColor || 'cyan'); + const isOpen = openIndex === index; + + return ( +
+ {/* Control */} + + {/* Panel */} +
+
+

+ {getLocalizedText(block.description)} +

+ {renderBlockButtons(block.buttons, 'light')} +
+
+
+ ); + })} +
+ ); +} diff --git a/src/components/connection/blocks/CardsBlock.tsx b/src/components/connection/blocks/CardsBlock.tsx new file mode 100644 index 0000000..54e7bcf --- /dev/null +++ b/src/components/connection/blocks/CardsBlock.tsx @@ -0,0 +1,42 @@ +import { getColorGradient } from '@/utils/colorParser'; +import { ThemeIcon } from './ThemeIcon'; +import type { BlockRendererProps } from './types'; + +export function CardsBlock({ + blocks, + isMobile, + getLocalizedText, + getSvgHtml, + renderBlockButtons, +}: BlockRendererProps) { + return ( +
+ {blocks.map((block, index) => { + const gradientStyle = getColorGradient(block.svgIconColor || 'cyan'); + + return ( +
+
+ +
+

{getLocalizedText(block.title)}

+

+ {getLocalizedText(block.description)} +

+ {renderBlockButtons(block.buttons, 'light')} +
+
+
+ ); + })} +
+ ); +} diff --git a/src/components/connection/blocks/MinimalBlock.tsx b/src/components/connection/blocks/MinimalBlock.tsx new file mode 100644 index 0000000..4912c27 --- /dev/null +++ b/src/components/connection/blocks/MinimalBlock.tsx @@ -0,0 +1,38 @@ +import { getColorGradient } from '@/utils/colorParser'; +import { ThemeIcon } from './ThemeIcon'; +import type { BlockRendererProps } from './types'; + +export function MinimalBlock({ + blocks, + isMobile, + getLocalizedText, + getSvgHtml, + renderBlockButtons, +}: BlockRendererProps) { + return ( +
+ {blocks.map((block, index) => { + const gradientStyle = getColorGradient(block.svgIconColor || 'cyan'); + const isLast = index === blocks.length - 1; + + return ( +
+
+ + {getLocalizedText(block.title)} +
+

+ {getLocalizedText(block.description)} +

+ {renderBlockButtons(block.buttons, 'subtle')} +
+ ); + })} +
+ ); +} diff --git a/src/components/connection/blocks/ThemeIcon.tsx b/src/components/connection/blocks/ThemeIcon.tsx new file mode 100644 index 0000000..e14d95b --- /dev/null +++ b/src/components/connection/blocks/ThemeIcon.tsx @@ -0,0 +1,34 @@ +import type { ColorGradientStyle } from '@/utils/colorParser'; + +interface ThemeIconProps { + getSvgHtml: (key: string | undefined) => string; + svgIconKey?: string; + gradientStyle: ColorGradientStyle; + isMobile: boolean; +} + +export function ThemeIcon({ getSvgHtml, svgIconKey, gradientStyle, isMobile }: ThemeIconProps) { + const svgHtml = getSvgHtml(svgIconKey); + if (!svgHtml) return null; + const size = isMobile ? 36 : 44; + const iconSize = isMobile ? 18 : 22; + + return ( +
+
+
+ ); +} diff --git a/src/components/connection/blocks/TimelineBlock.tsx b/src/components/connection/blocks/TimelineBlock.tsx new file mode 100644 index 0000000..fc41634 --- /dev/null +++ b/src/components/connection/blocks/TimelineBlock.tsx @@ -0,0 +1,43 @@ +import { getColorGradientSolid } from '@/utils/colorParser'; +import { ThemeIcon } from './ThemeIcon'; +import type { BlockRendererProps } from './types'; + +export function TimelineBlock({ + blocks, + isMobile, + getLocalizedText, + getSvgHtml, + renderBlockButtons, +}: BlockRendererProps) { + return ( +
+ {blocks.map((block, index) => { + const gradientStyle = getColorGradientSolid(block.svgIconColor || 'cyan'); + const isLast = index === blocks.length - 1; + + return ( +
+ {/* Left column: bullet + line segment */} +
+ + {!isLast &&
} +
+ {/* Right column: content */} +
+

{getLocalizedText(block.title)}

+

+ {getLocalizedText(block.description)} +

+ {renderBlockButtons(block.buttons, 'light')} +
+
+ ); + })} +
+ ); +} diff --git a/src/components/connection/blocks/index.ts b/src/components/connection/blocks/index.ts new file mode 100644 index 0000000..6e9e832 --- /dev/null +++ b/src/components/connection/blocks/index.ts @@ -0,0 +1,5 @@ +export { CardsBlock } from './CardsBlock'; +export { TimelineBlock } from './TimelineBlock'; +export { AccordionBlock } from './AccordionBlock'; +export { MinimalBlock } from './MinimalBlock'; +export type { BlockRendererProps } from './types'; diff --git a/src/components/connection/blocks/types.ts b/src/components/connection/blocks/types.ts new file mode 100644 index 0000000..d1592f2 --- /dev/null +++ b/src/components/connection/blocks/types.ts @@ -0,0 +1,12 @@ +import type { RemnawaveBlockClient, RemnawaveButtonClient, LocalizedText } from '@/types'; + +export interface BlockRendererProps { + blocks: RemnawaveBlockClient[]; + isMobile: boolean; + getLocalizedText: (text: LocalizedText | undefined) => string; + getSvgHtml: (key: string | undefined) => string; + renderBlockButtons: ( + buttons: RemnawaveButtonClient[] | undefined, + variant: 'light' | 'subtle', + ) => React.ReactNode; +} diff --git a/src/pages/AdminApps.tsx b/src/pages/AdminApps.tsx index ba61ed1..21d11a6 100644 --- a/src/pages/AdminApps.tsx +++ b/src/pages/AdminApps.tsx @@ -36,6 +36,7 @@ export default function AdminApps() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['remnawave-status'] }); queryClient.invalidateQueries({ queryKey: ['remnawave-config'] }); + queryClient.invalidateQueries({ queryKey: ['appConfig'] }); }, }); diff --git a/src/pages/Connection.tsx b/src/pages/Connection.tsx index b73fc26..ce1363b 100644 --- a/src/pages/Connection.tsx +++ b/src/pages/Connection.tsx @@ -1,182 +1,23 @@ -import { useState, useMemo, useEffect, useCallback, useRef } from 'react'; +import { useEffect, useCallback, useRef } from 'react'; 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, - RemnawaveAppClient, - RemnawavePlatformData, -} from '../types'; - -// Icons -const CopyIcon = () => ( - - - -); - -const CheckIcon = () => ( - - - -); - -const LinkIcon = () => ( - - - -); - -const ChevronIcon = () => ( - - - -); - -const BackIcon = () => ( - - - -); - -// App icons -const HappIcon = () => ( - - - - - - - - -); - -const ClashMetaIcon = () => ( - - - -); - -const ShadowrocketIcon = () => ( - - - -); - -const StreisandIcon = () => ( - - - -); - -const getAppIcon = (appName: string): React.ReactNode => { - const name = appName.toLowerCase(); - if (name.includes('happ')) return ; - if (name.includes('shadowrocket') || name.includes('rocket')) return ; - if (name.includes('streisand')) return ; - if (name.includes('clash') || name.includes('meta') || name.includes('verge')) - return ; - return πŸ“¦; -}; - -const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']; -// eslint-disable-next-line no-script-url -- listing dangerous schemes to block them -const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']; - -function isValidExternalUrl(url: string | undefined): boolean { - if (!url) return false; - const lowerUrl = url.toLowerCase().trim(); - if (dangerousSchemes.some((scheme) => lowerUrl.startsWith(scheme))) return false; - return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://'); -} - -function isValidDeepLink(url: string | undefined): boolean { - if (!url) return false; - const lowerUrl = url.toLowerCase().trim(); - if (dangerousSchemes.some((scheme) => lowerUrl.startsWith(scheme))) return false; - return lowerUrl.includes('://'); -} - -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; -} - -// 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 []; -} +import type { AppConfig, LocalizedText } from '../types'; +import InstallationGuide from '../components/connection/InstallationGuide'; 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(); - const scrollContainerRef = useRef(null); - // Ref для хранСния Π°ΠΊΡ‚ΡƒΠ°Π»ΡŒΠ½ΠΎΠ³ΠΎ ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊΠ° BackButton (фикс мигания) - const backButtonHandlerRef = useRef<() => void>(() => {}); - // Ref for haptic to avoid recreating handleBackButton const hapticRef = useRef(hapticImpact); hapticRef.current = hapticImpact; @@ -189,115 +30,28 @@ 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 || isRemnawave || selectedApp) 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 = getClassicApps(appConfig.platforms[platform] ?? []); - if (!apps.length) return; - const app = apps.find((a) => a.isFeatured) || apps[0]; - if (app) setSelectedApp(app); - }, [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); }, [navigate]); - const handleBack = useCallback(() => { - if (showAppSelector) { - if (selectedPlatform) { - setSelectedPlatform(null); - } else { - setShowAppSelector(false); - } - } else { - navigate(-1); - } - }, [showAppSelector, selectedPlatform, navigate]); - useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); - if (showAppSelector) handleBack(); - else handleGoBack(); + handleGoBack(); } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); - }, [handleGoBack, handleBack, showAppSelector]); + }, [handleGoBack]); - // ОбновляСм ref ΠΏΡ€ΠΈ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΈ Π»ΠΎΠ³ΠΈΠΊΠΈ (Π±Π΅Π· пСрСзапуска эффСкта BackButton) - useEffect(() => { - backButtonHandlerRef.current = showAppSelector ? handleBack : handleGoBack; - }, [showAppSelector, handleBack, handleGoBack]); - - // BackButton using platform hook - always close/back, ref provides current handler const handleBackButton = useCallback(() => { hapticRef.current('light'); - backButtonHandlerRef.current(); - }, []); + handleGoBack(); + }, [handleGoBack]); useBackButton(handleBackButton); - const getLocalizedText = (text: LocalizedText | undefined): string => { - if (!text) return ''; - const lang = i18n.language || 'en'; - return text[lang] || text['en'] || text['ru'] || Object.values(text)[0] || ''; - }; - - // Get translation from RemnaWave baseTranslations with i18n fallback - const getBaseTranslation = ( - key: string, - i18nKey: string, - interpolation?: Record, - ): 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, interpolation); - }; - - const availablePlatforms = useMemo(() => { - if (!appConfig?.platforms) return []; - const available = platformOrder.filter( - (key) => getPlatformAppsCount(appConfig.platforms[key] ?? []) > 0, - ); - if (detectedPlatform && available.includes(detectedPlatform)) { - return [detectedPlatform, ...available.filter((p) => p !== detectedPlatform)]; - } - return available; - }, [appConfig, detectedPlatform]); - - // Resolve templates in a URL using subscription URL + username const resolveUrl = useCallback( (url: string): string => { if (!hasTemplates(url) || !appConfig?.subscriptionUrl) return url; @@ -309,24 +63,6 @@ export default function Connection() { [appConfig?.subscriptionUrl, user?.username], ); - const copySubscriptionLink = async () => { - if (!appConfig?.subscriptionUrl) return; - try { - await navigator.clipboard.writeText(appConfig.subscriptionUrl); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - const textarea = document.createElement('textarea'); - textarea.value = appConfig.subscriptionUrl; - document.body.appendChild(textarea); - textarea.select(); - document.execCommand('copy'); - document.body.removeChild(textarea); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - }; - const openDeepLink = useCallback( (deepLink: string) => { let resolved = deepLink; @@ -334,8 +70,6 @@ export default function Connection() { resolved = resolveUrl(resolved); } - // Always use redirect page β€” opens in external browser where custom URL schemes work. - // Solves Telegram Mini App WebView limitation with custom schemes (happ://, clash://, etc.) const finalUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(resolved)}&lang=${i18n.language || 'en'}`; if (isTelegramWebApp) { @@ -352,25 +86,19 @@ export default function Connection() { [isTelegramWebApp, i18n.language, resolveUrl], ); - const handleConnect = (app: AppInfo) => { - if (!app.deepLink || !isValidDeepLink(app.deepLink)) return; - openDeepLink(app.deepLink); + const getLocalizedText = (text: LocalizedText | undefined): string => { + if (!text) return ''; + const lang = i18n.language || 'en'; + return text[lang] || text['en'] || text['ru'] || Object.values(text)[0] || ''; }; - // Resolve button links for step buttons - const resolveButtonLink = (link: string): string => { - return resolveUrl(link); - }; - - // Get sanitized SVG HTML - const getSvgHtml = (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 }, - }); + const getBaseTranslation = (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); }; // Loading @@ -409,667 +137,12 @@ export default function Connection() { ); } - // ============================================= - // RemnaWave format rendering - // ============================================= - if (isRemnawave) { - const currentApp = selectedRemnawaveApp; - - // Get platform display name from config or fallback - const getPlatformDisplayName = (key: string): string => { - // 1. displayName ΠΈΠ· Π΄Π°Π½Π½Ρ‹Ρ… ΠΏΠ»Π°Ρ‚Ρ„ΠΎΡ€ΠΌΡ‹ (RemnaWave) - const platformData = appConfig.platforms[key]; - if (platformData && !Array.isArray(platformData) && platformData.displayName) { - const name = getLocalizedText(platformData.displayName); - if (name) return name; - } - // 2. platformNames ΠΈΠ· бэкСнда (фоллбэк) - if (appConfig.platformNames?.[key]) { - return getLocalizedText(appConfig.platformNames[key]); - } - // 3. Π₯Π°Ρ€Π΄ΠΊΠΎΠ΄ фоллбэк (послСдний Ρ€ΡƒΠ±Π΅ΠΆ) - const fallback: Record = { - ios: 'iOS', - android: 'Android', - windows: 'Windows', - macos: 'macOS', - linux: 'Linux', - androidTV: 'Android TV', - appleTV: 'Apple TV', - }; - return fallback[key] || key; - }; - - // Current platform apps for chips - const currentPlatformKey = activePlatformKey || availablePlatforms[0]; - const currentPlatformData = currentPlatformKey - ? appConfig.platforms[currentPlatformKey] - : undefined; - const currentPlatformApps = currentPlatformData ? getRemnawaveApps(currentPlatformData) : []; - - // Current platform SVG icon for dropdown - const currentPlatformSvgKey = - currentPlatformData && isRemnawavePlatform(currentPlatformData) - ? currentPlatformData.svgIconKey - : undefined; - const currentPlatformSvg = getSvgHtml(currentPlatformSvgKey); - - // Main RemnaWave view β€” inline app chips + blocks - return ( -
- {/* Header + platform dropdown */} -
- {!isTelegramWebApp && ( - - )} -

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

- {/* Platform dropdown with icon */} - {availablePlatforms.length > 1 && ( -
- {currentPlatformSvg && ( -
- )} - -
- - - -
-
- )} -
- - {/* App cards grid (2 cols mobile, row on desktop) */} - {currentPlatformApps.length > 0 && ( -
- {currentPlatformApps.map((app, idx) => { - const isSelected = currentApp?.name === app.name; - const appIconSvg = getSvgHtml(app.svgIconKey); - return ( - - ); - })} -
- )} - - {/* Tutorial button */} - {appConfig.baseSettings?.isShowTutorialButton && appConfig.baseSettings?.tutorialUrl && ( - - - - - {getBaseTranslation('tutorial', 'subscription.connection.tutorial')} - - )} - - {/* Blocks */} - {currentApp && ( -
- {currentApp.blocks.map((block, blockIdx) => { - const svgHtml = getSvgHtml(block.svgIconKey); - const colorMap: Record = { - violet: '#8B5CF6', - cyan: '#06B6D4', - teal: '#14B8A6', - red: '#EF4444', - blue: '#3B82F6', - green: '#22C55E', - yellow: '#EAB308', - orange: '#F97316', - pink: '#EC4899', - indigo: '#6366F1', - amber: '#F59E0B', - }; - const rawColor = block.svgIconColor || 'violet'; - const iconColor = colorMap[rawColor] || rawColor; - - // Fallback block title from baseTranslations by block index - const blockTitleFallbackKeys = ['installApp', 'addSubscription', 'connectAndUse']; - const blockTitleFallbackI18n = [ - 'subscription.connection.installApp', - 'subscription.connection.addSubscription', - 'subscription.connection.connectVpn', - ]; - const blockTitle = - getLocalizedText(block.title) || - (blockIdx < blockTitleFallbackKeys.length - ? getBaseTranslation( - blockTitleFallbackKeys[blockIdx], - blockTitleFallbackI18n[blockIdx], - ) - : ''); - - return ( -
-
- {/* SVG icon */} - {svgHtml && ( -
-
-
- )} -
-

{blockTitle}

-

- {getLocalizedText(block.description)} -

- {/* Buttons */} - {block.buttons && block.buttons.length > 0 && ( -
- {block.buttons.map((btn, btnIdx) => { - const btnText = - getLocalizedText(btn.text) || - getBaseTranslation('openApp', 'subscription.connection.openLink'); - - if (btn.type === 'subscriptionLink') { - // Use app deepLink first, fallback to regular subscription URL - const deepLink = currentApp?.deepLink || appConfig.subscriptionUrl; - if (!deepLink || !isValidDeepLink(deepLink)) return null; - const subBtnSvg = getSvgHtml(btn.svgIconKey); - return ( - - ); - } - - if (btn.type === 'copyButton') { - if (appConfig?.hideLink) return null; - 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', - android: 'Android', - windows: 'Windows', - macos: 'macOS', - linux: 'Linux', - androidTV: 'Android TV', - appleTV: 'Apple TV', - }; - - const platformIcons: Record = { - ios: ( - - - - ), - android: ( - - - - ), - windows: ( - - - - ), - macos: ( - - - - ), - linux: ( - - - - ), - androidTV: ( - - - - ), - appleTV: ( - - - - ), - }; - - // Step 1: Platform selection - if (!selectedPlatform) { - return ( -
-
- {!isTelegramWebApp && ( - - )} -

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

-
-
- {availablePlatforms.map((platform) => { - 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; - - return ( - - ); - })} -
-
- ); - } - - // Step 2: App selection for chosen platform - const platformData = appConfig.platforms[selectedPlatform]; - const apps = platformData ? getClassicApps(platformData) : []; - const isCurrentPlatform = selectedPlatform === detectedPlatform; - - return ( -
-
- {!isTelegramWebApp && ( - - )} -
-

- {platformNames[selectedPlatform] || selectedPlatform} -

- {isCurrentPlatform && ( - - {t('subscription.connection.yourDevice')} - - )} -
-
-
-
- {apps.map((app) => { - const isSelected = selectedApp?.id === app.id; - return ( - - ); - })} -
-
-
- ); - } - - // Main view (classic) return ( -
-
-
-

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

- {!isTelegramWebApp && ( - - )} -
- -
- -
- {selectedApp?.installationStep && ( -
-
- - 1 - - - {t('subscription.connection.installApp')} - -
-

- {getLocalizedText(selectedApp.installationStep.description)} -

- {selectedApp.installationStep.buttons && - selectedApp.installationStep.buttons.length > 0 && ( -
- {selectedApp.installationStep.buttons - .filter((btn) => isValidExternalUrl(resolveButtonLink(btn.buttonLink))) - .map((btn, idx) => ( - - {getLocalizedText(btn.buttonText)} - - ))} -
- )} -
- )} - - {selectedApp?.addSubscriptionStep && ( -
-
- - 2 - - - {t('subscription.connection.addSubscription')} - -
-

- {getLocalizedText(selectedApp.addSubscriptionStep.description)} -

-
- {selectedApp.deepLink && ( - - )} - {/* Copy link button - hidden when hideLink is true */} - {!appConfig?.hideLink && ( - - )} -
-
- )} - - {selectedApp?.connectAndUseStep && ( -
-
- - 3 - - - {t('subscription.connection.connectVpn')} - -
-

- {getLocalizedText(selectedApp.connectAndUseStep.description)} -

-
- )} -
-
+ ); } diff --git a/src/types/index.ts b/src/types/index.ts index f7a572e..63da7ca 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -478,31 +478,7 @@ export interface LocalizedText { [key: string]: string; } -export interface AppButton { - id?: string; // Unique identifier for React key (client-side only) - buttonLink: string; - buttonText: LocalizedText; -} - -export interface AppStep { - description: LocalizedText; - buttons?: AppButton[]; - title?: LocalizedText; -} - -export interface AppInfo { - id: string; - name: string; - isFeatured: boolean; - deepLink?: string | null; - installationStep?: AppStep; - addSubscriptionStep?: AppStep; - connectAndUseStep?: AppStep; - additionalBeforeAddSubscriptionStep?: AppStep; - additionalAfterAddSubscriptionStep?: AppStep; -} - -// RemnaWave original format types +// RemnaWave format types export interface RemnawaveButtonClient { url?: string; link?: string; @@ -545,14 +521,16 @@ export interface AppConfig { supportUrl?: string; }; - // RemnaWave format (isRemnawave: true) + // RemnaWave isRemnawave?: boolean; svgLibrary?: Record; baseTranslations?: Record; baseSettings?: { isShowTutorialButton: boolean; tutorialUrl: string }; + uiConfig?: { + installationGuidesBlockType?: 'cards' | 'timeline' | 'accordion' | 'minimal'; + }; - // Platform data β€” either classic AppInfo[] or RemnaWave { apps: RemnawaveAppClient[] } - platforms: Record; + platforms: Record; } // Pending payment types diff --git a/src/utils/colorParser.ts b/src/utils/colorParser.ts new file mode 100644 index 0000000..fb25da7 --- /dev/null +++ b/src/utils/colorParser.ts @@ -0,0 +1,52 @@ +const COLORS: Record = { + cyan: [34, 211, 238], + teal: [32, 201, 151], + green: [64, 192, 87], + lime: [130, 201, 30], + yellow: [250, 176, 5], + orange: [253, 126, 20], + red: [250, 82, 82], + pink: [230, 73, 128], + grape: [190, 75, 219], + violet: [151, 117, 250], + indigo: [92, 124, 250], + blue: [34, 139, 230], + gray: [134, 142, 150], + dark: [55, 58, 64], +}; + +const DEFAULT_COLOR = COLORS.cyan; + +const hexToRgb = (hex: string): [number, number, number] | null => { + const match = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return match ? [parseInt(match[1], 16), parseInt(match[2], 16), parseInt(match[3], 16)] : null; +}; + +const getRgb = (color: string): [number, number, number] => + COLORS[color] ?? hexToRgb(color) ?? DEFAULT_COLOR; + +export interface ColorGradientStyle { + background: string; + border: string; + boxShadow?: string; +} + +export const getColorGradient = (color: string): ColorGradientStyle => { + const [r, g, b] = getRgb(color); + return { + background: `linear-gradient(135deg, rgba(${r},${g},${b},0.15) 0%, rgba(${r},${g},${b},0.08) 100%)`, + border: `1px solid rgba(${r},${g},${b},0.3)`, + }; +}; + +export const getColorGradientSolid = (color: string): ColorGradientStyle => { + const [r, g, b] = getRgb(color); + const dark1 = [22 + r * 0.08, 27 + g * 0.08, 35 + b * 0.08].map(Math.floor); + const dark2 = [20 + r * 0.05, 24 + g * 0.05, 30 + b * 0.05].map(Math.floor); + + return { + background: `linear-gradient(135deg, rgb(${dark1}) 0%, rgb(${dark2}) 100%)`, + border: `1px solid rgba(${r},${g},${b},0.4)`, + boxShadow: `inset 0 0 20px rgba(${r},${g},${b},0.15)`, + }; +};