mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
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:
438
src/components/connection/InstallationGuide.tsx
Normal file
438
src/components/connection/InstallationGuide.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/components/connection/blocks/AccordionBlock.tsx
Normal file
70
src/components/connection/blocks/AccordionBlock.tsx
Normal file
@@ -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<number | null>(0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{blocks.map((block, index) => {
|
||||||
|
const gradientStyle = getColorGradient(block.svgIconColor || 'cyan');
|
||||||
|
const isOpen = openIndex === index;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`overflow-hidden rounded-2xl border bg-dark-800/50 transition-colors ${
|
||||||
|
isOpen ? 'border-accent-500/30' : 'border-dark-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Control */}
|
||||||
|
<button
|
||||||
|
onClick={() => setOpenIndex(isOpen ? null : index)}
|
||||||
|
className="flex w-full items-center gap-3 p-4 text-left"
|
||||||
|
>
|
||||||
|
<ThemeIcon
|
||||||
|
getSvgHtml={getSvgHtml}
|
||||||
|
svgIconKey={block.svgIconKey}
|
||||||
|
gradientStyle={gradientStyle}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate font-semibold text-dark-100">
|
||||||
|
{getLocalizedText(block.title)}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className={`h-[18px] w-[18px] shrink-0 text-dark-400 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/* Panel */}
|
||||||
|
<div
|
||||||
|
className={`overflow-hidden transition-all duration-200 ${
|
||||||
|
isOpen ? 'max-h-[600px] opacity-100' : 'max-h-0 opacity-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
<p className="whitespace-pre-line text-sm leading-relaxed text-dark-400">
|
||||||
|
{getLocalizedText(block.description)}
|
||||||
|
</p>
|
||||||
|
{renderBlockButtons(block.buttons, 'light')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/components/connection/blocks/CardsBlock.tsx
Normal file
42
src/components/connection/blocks/CardsBlock.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{blocks.map((block, index) => {
|
||||||
|
const gradientStyle = getColorGradient(block.svgIconColor || 'cyan');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-4 sm:p-5"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3 sm:gap-4">
|
||||||
|
<ThemeIcon
|
||||||
|
getSvgHtml={getSvgHtml}
|
||||||
|
svgIconKey={block.svgIconKey}
|
||||||
|
gradientStyle={gradientStyle}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="font-semibold text-dark-100">{getLocalizedText(block.title)}</h3>
|
||||||
|
<p className="mt-1 whitespace-pre-line text-sm leading-relaxed text-dark-400">
|
||||||
|
{getLocalizedText(block.description)}
|
||||||
|
</p>
|
||||||
|
{renderBlockButtons(block.buttons, 'light')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/components/connection/blocks/MinimalBlock.tsx
Normal file
38
src/components/connection/blocks/MinimalBlock.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-0">
|
||||||
|
{blocks.map((block, index) => {
|
||||||
|
const gradientStyle = getColorGradient(block.svgIconColor || 'cyan');
|
||||||
|
const isLast = index === blocks.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={index} className={isLast ? '' : 'mb-4 border-b border-dark-700/50 pb-4'}>
|
||||||
|
<div className="mb-2 flex items-center gap-3">
|
||||||
|
<ThemeIcon
|
||||||
|
getSvgHtml={getSvgHtml}
|
||||||
|
svgIconKey={block.svgIconKey}
|
||||||
|
gradientStyle={gradientStyle}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
<span className="font-medium text-dark-100">{getLocalizedText(block.title)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="whitespace-pre-line text-sm leading-relaxed text-dark-400">
|
||||||
|
{getLocalizedText(block.description)}
|
||||||
|
</p>
|
||||||
|
{renderBlockButtons(block.buttons, 'subtle')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/components/connection/blocks/ThemeIcon.tsx
Normal file
34
src/components/connection/blocks/ThemeIcon.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center justify-center rounded-full"
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
background: gradientStyle.background,
|
||||||
|
border: gradientStyle.border,
|
||||||
|
boxShadow: gradientStyle.boxShadow,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{ width: iconSize, height: iconSize }}
|
||||||
|
className="[&>svg]:h-full [&>svg]:w-full"
|
||||||
|
dangerouslySetInnerHTML={{ __html: svgHtml }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/components/connection/blocks/TimelineBlock.tsx
Normal file
43
src/components/connection/blocks/TimelineBlock.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-0">
|
||||||
|
{blocks.map((block, index) => {
|
||||||
|
const gradientStyle = getColorGradientSolid(block.svgIconColor || 'cyan');
|
||||||
|
const isLast = index === blocks.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={index} className="flex gap-3 sm:gap-4">
|
||||||
|
{/* Left column: bullet + line segment */}
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<ThemeIcon
|
||||||
|
getSvgHtml={getSvgHtml}
|
||||||
|
svgIconKey={block.svgIconKey}
|
||||||
|
gradientStyle={gradientStyle}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
{!isLast && <div className="w-0.5 flex-1 bg-dark-700" />}
|
||||||
|
</div>
|
||||||
|
{/* Right column: content */}
|
||||||
|
<div className={`min-w-0 flex-1 ${isLast ? '' : 'pb-6'}`}>
|
||||||
|
<h3 className="font-semibold text-dark-100">{getLocalizedText(block.title)}</h3>
|
||||||
|
<p className="mt-1 whitespace-pre-line text-sm leading-relaxed text-dark-400">
|
||||||
|
{getLocalizedText(block.description)}
|
||||||
|
</p>
|
||||||
|
{renderBlockButtons(block.buttons, 'light')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/components/connection/blocks/index.ts
Normal file
5
src/components/connection/blocks/index.ts
Normal file
@@ -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';
|
||||||
12
src/components/connection/blocks/types.ts
Normal file
12
src/components/connection/blocks/types.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ export default function AdminApps() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['remnawave-status'] });
|
queryClient.invalidateQueries({ queryKey: ['remnawave-status'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['remnawave-config'] });
|
queryClient.invalidateQueries({ queryKey: ['remnawave-config'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['appConfig'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -478,31 +478,7 @@ export interface LocalizedText {
|
|||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppButton {
|
// RemnaWave format types
|
||||||
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
|
|
||||||
export interface RemnawaveButtonClient {
|
export interface RemnawaveButtonClient {
|
||||||
url?: string;
|
url?: string;
|
||||||
link?: string;
|
link?: string;
|
||||||
@@ -545,14 +521,16 @@ export interface AppConfig {
|
|||||||
supportUrl?: string;
|
supportUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// RemnaWave format (isRemnawave: true)
|
// RemnaWave
|
||||||
isRemnawave?: boolean;
|
isRemnawave?: boolean;
|
||||||
svgLibrary?: Record<string, string | { svgString: string }>;
|
svgLibrary?: Record<string, string | { svgString: string }>;
|
||||||
baseTranslations?: Record<string, LocalizedText>;
|
baseTranslations?: Record<string, LocalizedText>;
|
||||||
baseSettings?: { isShowTutorialButton: boolean; tutorialUrl: string };
|
baseSettings?: { isShowTutorialButton: boolean; tutorialUrl: string };
|
||||||
|
uiConfig?: {
|
||||||
|
installationGuidesBlockType?: 'cards' | 'timeline' | 'accordion' | 'minimal';
|
||||||
|
};
|
||||||
|
|
||||||
// Platform data — either classic AppInfo[] or RemnaWave { apps: RemnawaveAppClient[] }
|
platforms: Record<string, RemnawavePlatformData>;
|
||||||
platforms: Record<string, AppInfo[] | RemnawavePlatformData>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pending payment types
|
// Pending payment types
|
||||||
|
|||||||
52
src/utils/colorParser.ts
Normal file
52
src/utils/colorParser.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
const COLORS: Record<string, [number, number, number]> = {
|
||||||
|
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)`,
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user