From 5171890745c19808204e37251f7407ba28649b48 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Fri, 6 Feb 2026 18:05:40 +0300 Subject: [PATCH] refactor: extract BlockButtons component from InstallationGuide Move button rendering logic (URL validation, copy handler, icon components) into a dedicated BlockButtons component. Remove duplicate getLocalizedText/getBaseTranslation helpers from Connection page controller. --- .../connection/InstallationGuide.tsx | 160 +++--------------- .../connection/blocks/BlockButtons.tsx | 153 +++++++++++++++++ src/components/connection/blocks/index.ts | 1 + src/pages/Connection.tsx | 21 +-- 4 files changed, 176 insertions(+), 159 deletions(-) create mode 100644 src/components/connection/blocks/BlockButtons.tsx diff --git a/src/components/connection/InstallationGuide.tsx b/src/components/connection/InstallationGuide.tsx index cc2198d..53965b9 100644 --- a/src/components/connection/InstallationGuide.tsx +++ b/src/components/connection/InstallationGuide.tsx @@ -8,28 +8,11 @@ import type { RemnawavePlatformData, RemnawaveButtonClient, } from '@/types'; -import { CardsBlock, TimelineBlock, AccordionBlock, MinimalBlock } from './blocks'; +import { CardsBlock, TimelineBlock, AccordionBlock, MinimalBlock, BlockButtons } 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(); @@ -48,23 +31,6 @@ const RENDERERS: Record> = { minimal: MinimalBlock, }; -// Icons -const CopyIcon = () => ( - - - -); - -const CheckIcon = () => ( - - - -); - const BackIcon = () => ( @@ -91,7 +57,6 @@ export default function InstallationGuide({ const [activePlatformKey, setActivePlatformKey] = useState(null); const [selectedApp, setSelectedApp] = useState(null); - const [copied, setCopied] = useState(false); // --- Helpers --- @@ -155,117 +120,30 @@ export default function InstallationGuide({ } }, [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) --- + // --- Button renderer (delegates to BlockButtons component) --- 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} - - ); - })} -
- ); - }, + (buttons: RemnawaveButtonClient[] | undefined, variant: 'light' | 'subtle') => ( + + ), [ - selectedApp, - appConfig, - copied, - getSvgHtml, + appConfig.subscriptionUrl, + appConfig.hideLink, + selectedApp?.deepLink, getLocalizedText, getBaseTranslation, - handleCopy, + getSvgHtml, onOpenDeepLink, - t, ], ); diff --git a/src/components/connection/blocks/BlockButtons.tsx b/src/components/connection/blocks/BlockButtons.tsx new file mode 100644 index 0000000..e15ba10 --- /dev/null +++ b/src/components/connection/blocks/BlockButtons.tsx @@ -0,0 +1,153 @@ +import { useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { RemnawaveButtonClient, LocalizedText } from '@/types'; + +// 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://'); +} + +const CopyIcon = () => ( + + + +); + +const CheckIcon = () => ( + + + +); + +interface BlockButtonsProps { + buttons: RemnawaveButtonClient[] | undefined; + variant: 'light' | 'subtle'; + subscriptionUrl: string | null; + hideLink?: boolean; + deepLink?: string | null; + getLocalizedText: (text: LocalizedText | undefined) => string; + getBaseTranslation: (key: string, i18nKey: string) => string; + getSvgHtml: (key: string | undefined) => string; + onOpenDeepLink: (url: string) => void; +} + +export function BlockButtons({ + buttons, + variant, + subscriptionUrl, + hideLink, + deepLink, + getLocalizedText, + getBaseTranslation, + getSvgHtml, + onOpenDeepLink, +}: BlockButtonsProps) { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + + 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); + }, []); + + 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 || deepLink || subscriptionUrl; + if (!url || !isValidDeepLink(url)) return null; + return ( + + ); + } + + if (btn.type === 'copyButton') { + if (hideLink) return null; + const url = btn.resolvedUrl || subscriptionUrl; + if (!url) return null; + return ( + + ); + } + + // external + const href = btn.link || btn.url || ''; + if (!isValidExternalUrl(href)) return null; + return ( + + {btnIcon} + {btnText} + + ); + })} +
+ ); +} diff --git a/src/components/connection/blocks/index.ts b/src/components/connection/blocks/index.ts index 6e9e832..633e0e9 100644 --- a/src/components/connection/blocks/index.ts +++ b/src/components/connection/blocks/index.ts @@ -2,4 +2,5 @@ export { CardsBlock } from './CardsBlock'; export { TimelineBlock } from './TimelineBlock'; export { AccordionBlock } from './AccordionBlock'; export { MinimalBlock } from './MinimalBlock'; +export { BlockButtons } from './BlockButtons'; export type { BlockRendererProps } from './types'; diff --git a/src/pages/Connection.tsx b/src/pages/Connection.tsx index 2033fe3..a68d271 100644 --- a/src/pages/Connection.tsx +++ b/src/pages/Connection.tsx @@ -8,7 +8,7 @@ import { useTelegramSDK } from '../hooks/useTelegramSDK'; import { useHaptic } from '@/platform'; import { resolveTemplate, hasTemplates } from '../utils/templateEngine'; import { useAuthStore } from '../store/auth'; -import type { AppConfig, LocalizedText } from '../types'; +import type { AppConfig } from '../types'; import InstallationGuide from '../components/connection/InstallationGuide'; export default function Connection() { @@ -79,21 +79,6 @@ export default function Connection() { [isTelegramWebApp, i18n.language, resolveUrl], ); - 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] || ''; - }; - - 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 if (isLoading) { return ( @@ -109,7 +94,7 @@ export default function Connection() {

{t('common.error')}

); @@ -124,7 +109,7 @@ export default function Connection() {

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

);