mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
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.
This commit is contained in:
@@ -8,28 +8,11 @@ import type {
|
|||||||
RemnawavePlatformData,
|
RemnawavePlatformData,
|
||||||
RemnawaveButtonClient,
|
RemnawaveButtonClient,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
import { CardsBlock, TimelineBlock, AccordionBlock, MinimalBlock } from './blocks';
|
import { CardsBlock, TimelineBlock, AccordionBlock, MinimalBlock, BlockButtons } from './blocks';
|
||||||
import type { BlockRendererProps } from './blocks';
|
import type { BlockRendererProps } from './blocks';
|
||||||
|
|
||||||
const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV'];
|
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 {
|
function detectPlatform(): string | null {
|
||||||
if (typeof window === 'undefined' || !navigator?.userAgent) return null;
|
if (typeof window === 'undefined' || !navigator?.userAgent) return null;
|
||||||
const ua = navigator.userAgent.toLowerCase();
|
const ua = navigator.userAgent.toLowerCase();
|
||||||
@@ -48,23 +31,6 @@ const RENDERERS: Record<string, React.ComponentType<BlockRendererProps>> = {
|
|||||||
minimal: MinimalBlock,
|
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 = () => (
|
const BackIcon = () => (
|
||||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
@@ -91,7 +57,6 @@ export default function InstallationGuide({
|
|||||||
|
|
||||||
const [activePlatformKey, setActivePlatformKey] = useState<string | null>(null);
|
const [activePlatformKey, setActivePlatformKey] = useState<string | null>(null);
|
||||||
const [selectedApp, setSelectedApp] = useState<RemnawaveAppClient | null>(null);
|
const [selectedApp, setSelectedApp] = useState<RemnawaveAppClient | null>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
@@ -155,117 +120,30 @@ export default function InstallationGuide({
|
|||||||
}
|
}
|
||||||
}, [appConfig.platforms, availablePlatforms, selectedApp]);
|
}, [appConfig.platforms, availablePlatforms, selectedApp]);
|
||||||
|
|
||||||
// --- Copy ---
|
// --- Button renderer (delegates to BlockButtons component) ---
|
||||||
|
|
||||||
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(
|
const renderBlockButtons = useCallback(
|
||||||
(buttons: RemnawaveButtonClient[] | undefined, variant: 'light' | 'subtle') => {
|
(buttons: RemnawaveButtonClient[] | undefined, variant: 'light' | 'subtle') => (
|
||||||
if (!buttons || buttons.length === 0) return null;
|
<BlockButtons
|
||||||
|
buttons={buttons}
|
||||||
const baseClass =
|
variant={variant}
|
||||||
variant === 'light'
|
subscriptionUrl={appConfig.subscriptionUrl}
|
||||||
? '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'
|
hideLink={appConfig.hideLink}
|
||||||
: 'rounded-xl px-3 py-1.5 text-sm font-medium text-dark-300 transition-all hover:bg-dark-700/50';
|
deepLink={selectedApp?.deepLink}
|
||||||
|
getLocalizedText={getLocalizedText}
|
||||||
return (
|
getBaseTranslation={getBaseTranslation}
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
getSvgHtml={getSvgHtml}
|
||||||
{buttons.map((btn, idx) => {
|
onOpenDeepLink={onOpenDeepLink}
|
||||||
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.subscriptionUrl,
|
||||||
appConfig,
|
appConfig.hideLink,
|
||||||
copied,
|
selectedApp?.deepLink,
|
||||||
getSvgHtml,
|
|
||||||
getLocalizedText,
|
getLocalizedText,
|
||||||
getBaseTranslation,
|
getBaseTranslation,
|
||||||
handleCopy,
|
getSvgHtml,
|
||||||
onOpenDeepLink,
|
onOpenDeepLink,
|
||||||
t,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
153
src/components/connection/blocks/BlockButtons.tsx
Normal file
153
src/components/connection/blocks/BlockButtons.tsx
Normal file
@@ -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 = () => (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<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 || deepLink || 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 (hideLink) return null;
|
||||||
|
const url = btn.resolvedUrl || 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,4 +2,5 @@ export { CardsBlock } from './CardsBlock';
|
|||||||
export { TimelineBlock } from './TimelineBlock';
|
export { TimelineBlock } from './TimelineBlock';
|
||||||
export { AccordionBlock } from './AccordionBlock';
|
export { AccordionBlock } from './AccordionBlock';
|
||||||
export { MinimalBlock } from './MinimalBlock';
|
export { MinimalBlock } from './MinimalBlock';
|
||||||
|
export { BlockButtons } from './BlockButtons';
|
||||||
export type { BlockRendererProps } from './types';
|
export type { BlockRendererProps } from './types';
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useTelegramSDK } from '../hooks/useTelegramSDK';
|
|||||||
import { useHaptic } from '@/platform';
|
import { useHaptic } from '@/platform';
|
||||||
import { resolveTemplate, hasTemplates } from '../utils/templateEngine';
|
import { resolveTemplate, hasTemplates } from '../utils/templateEngine';
|
||||||
import { useAuthStore } from '../store/auth';
|
import { useAuthStore } from '../store/auth';
|
||||||
import type { AppConfig, LocalizedText } from '../types';
|
import type { AppConfig } from '../types';
|
||||||
import InstallationGuide from '../components/connection/InstallationGuide';
|
import InstallationGuide from '../components/connection/InstallationGuide';
|
||||||
|
|
||||||
export default function Connection() {
|
export default function Connection() {
|
||||||
@@ -79,21 +79,6 @@ export default function Connection() {
|
|||||||
[isTelegramWebApp, i18n.language, resolveUrl],
|
[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
|
// Loading
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -109,7 +94,7 @@ export default function Connection() {
|
|||||||
<div className="flex flex-1 flex-col items-center justify-center p-8 text-center">
|
<div className="flex flex-1 flex-col items-center justify-center p-8 text-center">
|
||||||
<p className="mb-4 text-lg text-dark-300">{t('common.error')}</p>
|
<p className="mb-4 text-lg text-dark-300">{t('common.error')}</p>
|
||||||
<button onClick={handleGoBack} className="btn-primary px-6 py-2">
|
<button onClick={handleGoBack} className="btn-primary px-6 py-2">
|
||||||
{getBaseTranslation('close', 'common.close')}
|
{t('common.close')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -124,7 +109,7 @@ export default function Connection() {
|
|||||||
</h3>
|
</h3>
|
||||||
<p className="mb-4 text-dark-400">{t('subscription.connection.noSubscription')}</p>
|
<p className="mb-4 text-dark-400">{t('subscription.connection.noSubscription')}</p>
|
||||||
<button onClick={handleGoBack} className="btn-primary px-6 py-2">
|
<button onClick={handleGoBack} className="btn-primary px-6 py-2">
|
||||||
{getBaseTranslation('close', 'common.close')}
|
{t('common.close')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user