refactor: extract Installation Guides into block renderer components

Replace monolithic Connection.tsx (1076 lines) with modular architecture:
- 4 block renderers (cards, timeline, accordion, minimal) selectable
  via uiConfig.installationGuidesBlockType from RemnaWave config
- Color parser utility with 14 named colors + hex support
- InstallationGuide component handles platform/app selection and
  button rendering (subscriptionLink, copyButton, external)
- Remove classic step-based format (AppButton, AppStep, AppInfo)
- Add appConfig query invalidation on admin UUID change
This commit is contained in:
c0mrade
2026-02-05 20:07:07 +03:00
parent 5111b63f2e
commit 813f6e4449
12 changed files with 765 additions and 979 deletions

View File

@@ -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<string, React.ComponentType<BlockRendererProps>> = {
cards: CardsBlock,
timeline: TimelineBlock,
accordion: AccordionBlock,
minimal: MinimalBlock,
};
// Icons
const CopyIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
);
const CheckIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
);
const BackIcon = () => (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
);
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<string | null>(null);
const [selectedApp, setSelectedApp] = useState<RemnawaveAppClient | null>(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 (
<div className="mt-3 flex flex-wrap gap-2">
{buttons.map((btn, idx) => {
const btnText = getLocalizedText(btn.text);
const btnSvg = getSvgHtml(btn.svgIconKey);
const btnIcon = btnSvg ? (
<div
className="h-4 w-4 [&>svg]:h-full [&>svg]:w-full"
dangerouslySetInnerHTML={{ __html: 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 (
<button
key={idx}
onClick={() => onOpenDeepLink(url)}
className={`flex items-center gap-2 ${baseClass}`}
>
{btnIcon}
{btnText || getBaseTranslation('openApp', 'subscription.connection.openLink')}
</button>
);
}
if (btn.type === 'copyButton') {
if (appConfig.hideLink) return null;
const url = btn.resolvedUrl || appConfig.subscriptionUrl;
if (!url) return null;
return (
<button
key={idx}
onClick={() => handleCopy(url)}
className={`flex items-center gap-2 ${
copied
? 'rounded-xl border border-success-500 bg-success-500/10 px-4 py-2 text-sm font-medium text-success-400'
: baseClass
}`}
>
{copied ? <CheckIcon /> : btnIcon || <CopyIcon />}
{copied
? t('subscription.connection.copied')
: btnText || getBaseTranslation('copyLink', 'subscription.connection.copyLink')}
</button>
);
}
// external
const href = btn.link || btn.url || '';
if (!isValidExternalUrl(href)) return null;
return (
<a
key={idx}
href={href}
target="_blank"
rel="noopener noreferrer"
className={`inline-flex items-center gap-2 ${baseClass}`}
>
{btnIcon}
{btnText}
</a>
);
})}
</div>
);
},
[
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<string, string> = {
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 (
<div className="space-y-6">
{/* Header + platform dropdown */}
<div className="flex items-center gap-3">
{!isTelegramWebApp && (
<button
onClick={onGoBack}
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
>
<BackIcon />
</button>
)}
<h2 className="flex-1 text-lg font-bold text-dark-100">
{getBaseTranslation('installationGuideHeader', 'subscription.connection.title')}
</h2>
{availablePlatforms.length > 1 && (
<div className="relative flex items-center">
{currentPlatformSvg && (
<div
className="pointer-events-none absolute left-3 z-10 h-5 w-5 text-dark-400 [&>svg]:h-full [&>svg]:w-full"
dangerouslySetInnerHTML={{ __html: currentPlatformSvg }}
/>
)}
<select
value={currentPlatformKey || ''}
onChange={(e) => {
const newPlatform = e.target.value;
setActivePlatformKey(newPlatform);
const data = appConfig.platforms[newPlatform] as RemnawavePlatformData | undefined;
if (data?.apps?.length) {
const app = data.apps.find((a) => a.featured) || data.apps[0];
if (app) setSelectedApp(app);
}
}}
className={`appearance-none rounded-xl border border-dark-700 bg-dark-800 py-2 pr-8 text-sm font-medium text-dark-200 outline-none transition-colors hover:border-dark-600 ${
currentPlatformSvg ? 'pl-10' : 'pl-4'
}`}
>
{availablePlatforms.map((p) => (
<option key={p} value={p}>
{getPlatformDisplayName(p)}
</option>
))}
</select>
<div className="pointer-events-none absolute right-2.5 text-dark-400">
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 9l4-4 4 4M8 15l4 4 4-4" />
</svg>
</div>
</div>
)}
</div>
{/* App chips */}
{currentPlatformApps.length > 0 && (
<div className="flex flex-wrap gap-2">
{currentPlatformApps.map((app, idx) => {
const isSelected = selectedApp?.name === app.name;
const appIconSvg = getSvgHtml(app.svgIconKey);
return (
<button
key={app.name + idx}
onClick={() => setSelectedApp(app)}
className={`relative flex min-w-[calc(50%-0.25rem)] items-center gap-2 overflow-hidden rounded-xl px-4 py-2 text-sm font-medium transition-all active:scale-[0.97] ${
isSelected
? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/40'
: 'border border-dark-700/50 bg-dark-800/80 text-dark-200 hover:border-dark-600/50 hover:bg-dark-700/80'
}`}
>
{app.featured && <span className="h-2 w-2 shrink-0 rounded-full bg-amber-400" />}
<span className="relative z-10 truncate">{app.name}</span>
{appIconSvg && (
<div
className="ml-auto h-7 w-7 shrink-0 opacity-30 [&>svg]:h-full [&>svg]:w-full"
dangerouslySetInnerHTML={{ __html: appIconSvg }}
/>
)}
</button>
);
})}
</div>
)}
{/* Tutorial button */}
{appConfig.baseSettings?.isShowTutorialButton && appConfig.baseSettings?.tutorialUrl && (
<a
href={appConfig.baseSettings.tutorialUrl}
target="_blank"
rel="noopener noreferrer"
className="btn-secondary w-full justify-center"
>
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
/>
</svg>
{getBaseTranslation('tutorial', 'subscription.connection.tutorial')}
</a>
)}
{/* Blocks */}
{selectedApp && (
<Renderer
blocks={selectedApp.blocks}
isMobile={isMobile}
getLocalizedText={getLocalizedText}
getSvgHtml={getSvgHtml}
renderBlockButtons={renderBlockButtons}
/>
)}
</div>
);
}