feat: convert ConnectionModal to /connection page with crypto deep links

- Add @kastov/cryptohapp + jsencrypt for encrypted Happ deep links
- Add templateEngine utility to resolve {{SUBSCRIPTION_LINK}},
  {{HAPP_CRYPT3_LINK}}, {{HAPP_CRYPT4_LINK}}, {{USERNAME}} templates
- Convert ConnectionModal component into a standalone Connection page
- Add /connection route with ProtectedRoute and lazy loading
- Replace modal invocation in Dashboard and Subscription with
  navigate('/connection')
- Add type and svgIconKey optional fields to RemnawaveButton
- Show button type badge and SVG icon in AdminApps BlockCard
- Refactor ThemeTab to use local draft state with Save/Cancel flow
This commit is contained in:
c0mrade
2026-02-05 07:33:45 +03:00
parent 35f499673f
commit 445dd0601a
10 changed files with 580 additions and 1214 deletions

View File

@@ -26,6 +26,7 @@ const Contests = lazy(() => import('./pages/Contests'));
const Polls = lazy(() => import('./pages/Polls'));
const Info = lazy(() => import('./pages/Info'));
const Wheel = lazy(() => import('./pages/Wheel'));
const Connection = lazy(() => import('./pages/Connection'));
const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect'));
const TopUpAmount = lazy(() => import('./pages/TopUpAmount'));
@@ -262,6 +263,16 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/connection"
element={
<ProtectedRoute>
<LazyPage>
<Connection />
</LazyPage>
</ProtectedRoute>
}
/>
{/* Admin routes */}
<Route

View File

@@ -50,91 +50,6 @@ export interface AppConfigResponse {
}
export const adminAppsApi = {
// Get full app config
getConfig: async (): Promise<AppConfigResponse> => {
const response = await apiClient.get<AppConfigResponse>('/cabinet/admin/apps');
return response.data;
},
// Get available platforms
getPlatforms: async (): Promise<string[]> => {
const response = await apiClient.get<string[]>('/cabinet/admin/apps/platforms');
return response.data;
},
// Get apps for a platform
getPlatformApps: async (platform: string): Promise<AppDefinition[]> => {
const response = await apiClient.get<AppDefinition[]>(
`/cabinet/admin/apps/platforms/${platform}`,
);
return response.data;
},
// Create a new app
createApp: async (platform: string, app: AppDefinition): Promise<AppDefinition> => {
const response = await apiClient.post<AppDefinition>(
`/cabinet/admin/apps/platforms/${platform}`,
{
platform,
app,
},
);
return response.data;
},
// Update an app
updateApp: async (
platform: string,
appId: string,
app: AppDefinition,
): Promise<AppDefinition> => {
const response = await apiClient.put<AppDefinition>(
`/cabinet/admin/apps/platforms/${platform}/${appId}`,
{
app,
},
);
return response.data;
},
// Delete an app
deleteApp: async (platform: string, appId: string): Promise<void> => {
await apiClient.delete(`/cabinet/admin/apps/platforms/${platform}/${appId}`);
},
// Reorder apps
reorderApps: async (platform: string, appIds: string[]): Promise<void> => {
await apiClient.post(`/cabinet/admin/apps/platforms/${platform}/reorder`, {
app_ids: appIds,
});
},
// Copy app to another platform
copyApp: async (
platform: string,
appId: string,
targetPlatform: string,
): Promise<{ new_id: string }> => {
const response = await apiClient.post<{ new_id: string; target_platform: string }>(
`/cabinet/admin/apps/platforms/${platform}/copy/${appId}?target_platform=${targetPlatform}`,
);
return response.data;
},
// Get branding
getBranding: async (): Promise<AppConfigBranding> => {
const response = await apiClient.get<AppConfigBranding>('/cabinet/admin/apps/branding');
return response.data;
},
// Update branding
updateBranding: async (branding: AppConfigBranding): Promise<AppConfigBranding> => {
const response = await apiClient.put<AppConfigBranding>('/cabinet/admin/apps/branding', {
branding,
});
return response.data;
},
// Get RemnaWave config status
getRemnaWaveStatus: async (): Promise<{ enabled: boolean; config_uuid: string | null }> => {
const response = await apiClient.get<{ enabled: boolean; config_uuid: string | null }>(
@@ -181,6 +96,8 @@ export const adminAppsApi = {
export interface RemnawaveButton {
url: string;
text: LocalizedText;
type?: 'external' | 'subscriptionLink' | 'copyButton';
svgIconKey?: string;
}
export interface RemnawaveBlock {

View File

@@ -1,8 +1,8 @@
import { useState } from 'react';
import { useEffect, useState, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { themeColorsApi } from '../../api/themeColors';
import { DEFAULT_THEME_COLORS } from '../../types/theme';
import { DEFAULT_THEME_COLORS, ThemeColors } from '../../types/theme';
import { ColorPicker } from '../ColorPicker';
import { applyThemeColors } from '../../hooks/useThemeColors';
import { updateEnabledThemesCache } from '../../hooks/useTheme';
@@ -10,6 +10,23 @@ import { MoonIcon, SunIcon, ChevronDownIcon } from './icons';
import { Toggle } from './Toggle';
import { THEME_PRESETS } from './constants';
function colorsEqual(a: ThemeColors, b: ThemeColors): boolean {
return (
a.accent === b.accent &&
a.darkBackground === b.darkBackground &&
a.darkSurface === b.darkSurface &&
a.darkText === b.darkText &&
a.darkTextSecondary === b.darkTextSecondary &&
a.lightBackground === b.lightBackground &&
a.lightSurface === b.lightSurface &&
a.lightText === b.lightText &&
a.lightTextSecondary === b.lightTextSecondary &&
a.success === b.success &&
a.warning === b.warning &&
a.error === b.error
);
}
export function ThemeTab() {
const { t } = useTranslation();
const queryClient = useQueryClient();
@@ -29,7 +46,7 @@ export function ThemeTab() {
};
// Queries
const { data: themeColors } = useQuery({
const { data: serverColors } = useQuery({
queryKey: ['theme-colors'],
queryFn: themeColorsApi.getColors,
});
@@ -39,20 +56,120 @@ export function ThemeTab() {
queryFn: themeColorsApi.getEnabledThemes,
});
// Local draft state
const [draftColors, setDraftColors] = useState<ThemeColors>(DEFAULT_THEME_COLORS);
const savedColorsRef = useRef<ThemeColors>(DEFAULT_THEME_COLORS);
// Sync server data into draft and saved snapshot when it arrives
useEffect(() => {
if (serverColors) {
const colors: ThemeColors = {
accent: serverColors.accent,
darkBackground: serverColors.darkBackground,
darkSurface: serverColors.darkSurface,
darkText: serverColors.darkText,
darkTextSecondary: serverColors.darkTextSecondary,
lightBackground: serverColors.lightBackground,
lightSurface: serverColors.lightSurface,
lightText: serverColors.lightText,
lightTextSecondary: serverColors.lightTextSecondary,
success: serverColors.success,
warning: serverColors.warning,
error: serverColors.error,
};
// Only sync if saved snapshot matches current draft (no unsaved changes)
if (
colorsEqual(savedColorsRef.current, draftColors) ||
colorsEqual(savedColorsRef.current, DEFAULT_THEME_COLORS)
) {
setDraftColors(colors);
}
savedColorsRef.current = colors;
}
}, [serverColors]); // eslint-disable-line react-hooks/exhaustive-deps
const hasUnsavedChanges = !colorsEqual(draftColors, savedColorsRef.current);
// Update a single color in the draft and apply preview instantly
const updateDraftColor = useCallback(
(key: keyof ThemeColors, value: string) => {
setDraftColors((prev) => {
const next = { ...prev, [key]: value };
applyThemeColors(next);
queryClient.setQueryData(['theme-colors'], next);
return next;
});
},
[queryClient],
);
// Apply a full preset to draft
const applyPreset = useCallback(
(colors: Partial<ThemeColors>) => {
setDraftColors((prev) => {
const next = { ...prev, ...colors };
applyThemeColors(next);
queryClient.setQueryData(['theme-colors'], next);
return next;
});
},
[queryClient],
);
// Cancel: revert draft to saved
const handleCancel = useCallback(() => {
const saved = savedColorsRef.current;
setDraftColors(saved);
applyThemeColors(saved);
queryClient.setQueryData(['theme-colors'], saved);
}, [queryClient]);
// Mutations
const updateColorsMutation = useMutation({
mutationFn: themeColorsApi.updateColors,
onSuccess: (data) => {
applyThemeColors(data);
queryClient.invalidateQueries({ queryKey: ['theme-colors'] });
const colors: ThemeColors = {
accent: data.accent,
darkBackground: data.darkBackground,
darkSurface: data.darkSurface,
darkText: data.darkText,
darkTextSecondary: data.darkTextSecondary,
lightBackground: data.lightBackground,
lightSurface: data.lightSurface,
lightText: data.lightText,
lightTextSecondary: data.lightTextSecondary,
success: data.success,
warning: data.warning,
error: data.error,
};
savedColorsRef.current = colors;
setDraftColors(colors);
applyThemeColors(colors);
queryClient.setQueryData(['theme-colors'], data);
},
});
const resetColorsMutation = useMutation({
mutationFn: themeColorsApi.resetColors,
onSuccess: (data) => {
applyThemeColors(data);
queryClient.invalidateQueries({ queryKey: ['theme-colors'] });
const colors: ThemeColors = {
accent: data.accent,
darkBackground: data.darkBackground,
darkSurface: data.darkSurface,
darkText: data.darkText,
darkTextSecondary: data.darkTextSecondary,
lightBackground: data.lightBackground,
lightSurface: data.lightSurface,
lightText: data.lightText,
lightTextSecondary: data.lightTextSecondary,
success: data.success,
warning: data.warning,
error: data.error,
};
savedColorsRef.current = colors;
setDraftColors(colors);
applyThemeColors(colors);
queryClient.setQueryData(['theme-colors'], data);
},
});
@@ -130,8 +247,7 @@ export function ThemeTab() {
{THEME_PRESETS.map((preset) => (
<button
key={preset.id}
onClick={() => updateColorsMutation.mutate(preset.colors)}
disabled={updateColorsMutation.isPending}
onClick={() => applyPreset(preset.colors)}
className="rounded-xl border border-dark-600 p-3 transition-all hover:scale-[1.02] hover:border-dark-500"
style={{ backgroundColor: preset.colors.darkBackground }}
>
@@ -189,9 +305,8 @@ export function ThemeTab() {
</h4>
<ColorPicker
label={t('theme.accent')}
value={themeColors?.accent || DEFAULT_THEME_COLORS.accent}
onChange={(color) => updateColorsMutation.mutate({ accent: color })}
disabled={updateColorsMutation.isPending}
value={draftColors.accent}
onChange={(color) => updateDraftColor('accent', color)}
/>
</div>
@@ -203,27 +318,23 @@ export function ThemeTab() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<ColorPicker
label={t('admin.settings.colors.background')}
value={themeColors?.darkBackground || DEFAULT_THEME_COLORS.darkBackground}
onChange={(color) => updateColorsMutation.mutate({ darkBackground: color })}
disabled={updateColorsMutation.isPending}
value={draftColors.darkBackground}
onChange={(color) => updateDraftColor('darkBackground', color)}
/>
<ColorPicker
label={t('admin.settings.colors.surface')}
value={themeColors?.darkSurface || DEFAULT_THEME_COLORS.darkSurface}
onChange={(color) => updateColorsMutation.mutate({ darkSurface: color })}
disabled={updateColorsMutation.isPending}
value={draftColors.darkSurface}
onChange={(color) => updateDraftColor('darkSurface', color)}
/>
<ColorPicker
label={t('admin.settings.colors.text')}
value={themeColors?.darkText || DEFAULT_THEME_COLORS.darkText}
onChange={(color) => updateColorsMutation.mutate({ darkText: color })}
disabled={updateColorsMutation.isPending}
value={draftColors.darkText}
onChange={(color) => updateDraftColor('darkText', color)}
/>
<ColorPicker
label={t('admin.settings.colors.textSecondary')}
value={themeColors?.darkTextSecondary || DEFAULT_THEME_COLORS.darkTextSecondary}
onChange={(color) => updateColorsMutation.mutate({ darkTextSecondary: color })}
disabled={updateColorsMutation.isPending}
value={draftColors.darkTextSecondary}
onChange={(color) => updateDraftColor('darkTextSecondary', color)}
/>
</div>
</div>
@@ -236,27 +347,23 @@ export function ThemeTab() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<ColorPicker
label={t('admin.settings.colors.background')}
value={themeColors?.lightBackground || DEFAULT_THEME_COLORS.lightBackground}
onChange={(color) => updateColorsMutation.mutate({ lightBackground: color })}
disabled={updateColorsMutation.isPending}
value={draftColors.lightBackground}
onChange={(color) => updateDraftColor('lightBackground', color)}
/>
<ColorPicker
label={t('admin.settings.colors.surface')}
value={themeColors?.lightSurface || DEFAULT_THEME_COLORS.lightSurface}
onChange={(color) => updateColorsMutation.mutate({ lightSurface: color })}
disabled={updateColorsMutation.isPending}
value={draftColors.lightSurface}
onChange={(color) => updateDraftColor('lightSurface', color)}
/>
<ColorPicker
label={t('admin.settings.colors.text')}
value={themeColors?.lightText || DEFAULT_THEME_COLORS.lightText}
onChange={(color) => updateColorsMutation.mutate({ lightText: color })}
disabled={updateColorsMutation.isPending}
value={draftColors.lightText}
onChange={(color) => updateDraftColor('lightText', color)}
/>
<ColorPicker
label={t('admin.settings.colors.textSecondary')}
value={themeColors?.lightTextSecondary || DEFAULT_THEME_COLORS.lightTextSecondary}
onChange={(color) => updateColorsMutation.mutate({ lightTextSecondary: color })}
disabled={updateColorsMutation.isPending}
value={draftColors.lightTextSecondary}
onChange={(color) => updateDraftColor('lightTextSecondary', color)}
/>
</div>
</div>
@@ -269,33 +376,52 @@ export function ThemeTab() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<ColorPicker
label={t('admin.settings.colors.success')}
value={themeColors?.success || DEFAULT_THEME_COLORS.success}
onChange={(color) => updateColorsMutation.mutate({ success: color })}
disabled={updateColorsMutation.isPending}
value={draftColors.success}
onChange={(color) => updateDraftColor('success', color)}
/>
<ColorPicker
label={t('admin.settings.colors.warning')}
value={themeColors?.warning || DEFAULT_THEME_COLORS.warning}
onChange={(color) => updateColorsMutation.mutate({ warning: color })}
disabled={updateColorsMutation.isPending}
value={draftColors.warning}
onChange={(color) => updateDraftColor('warning', color)}
/>
<ColorPicker
label={t('admin.settings.colors.error')}
value={themeColors?.error || DEFAULT_THEME_COLORS.error}
onChange={(color) => updateColorsMutation.mutate({ error: color })}
disabled={updateColorsMutation.isPending}
value={draftColors.error}
onChange={(color) => updateDraftColor('error', color)}
/>
</div>
</div>
{/* Reset button */}
<button
onClick={() => resetColorsMutation.mutate()}
disabled={resetColorsMutation.isPending}
className="rounded-xl bg-dark-700 px-4 py-2 text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
>
{t('admin.settings.resetAllColors')}
</button>
{/* Save / Cancel / Reset buttons */}
<div className="flex flex-wrap items-center gap-3">
{hasUnsavedChanges && (
<>
<button
onClick={() => updateColorsMutation.mutate(draftColors)}
disabled={updateColorsMutation.isPending}
className="rounded-xl bg-accent-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
>
{updateColorsMutation.isPending
? t('common.saving', t('common.save'))
: t('common.save')}
</button>
<button
onClick={handleCancel}
disabled={updateColorsMutation.isPending}
className="rounded-xl bg-dark-700 px-4 py-2 text-sm font-medium text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
>
{t('common.cancel')}
</button>
</>
)}
<button
onClick={() => resetColorsMutation.mutate()}
disabled={resetColorsMutation.isPending}
className="rounded-xl bg-dark-700 px-4 py-2 text-sm text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
>
{t('admin.settings.resetAllColors')}
</button>
</div>
</div>
)}
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +1,16 @@
import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
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 { 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 } from '../types';
interface ConnectionModalProps {
onClose: () => void;
}
// Icons
const CloseIcon = () => (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
const CopyIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
@@ -138,31 +130,17 @@ function detectPlatform(): string | null {
return null;
}
function useIsMobile() {
const [isMobile, setIsMobile] = useState(() => {
if (typeof window === 'undefined') return false;
return window.innerWidth < 768;
});
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768);
window.addEventListener('resize', check);
return () => window.removeEventListener('resize', check);
}, []);
return isMobile;
}
export default function ConnectionModal({ onClose }: ConnectionModalProps) {
export default function Connection() {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const { user } = useAuthStore();
const [selectedApp, setSelectedApp] = useState<AppInfo | null>(null);
const [copied, setCopied] = useState(false);
const [showAppSelector, setShowAppSelector] = useState(false);
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
const { isTelegramWebApp, isFullscreen, safeAreaInset, contentSafeAreaInset } =
useTelegramWebApp();
const { isTelegramWebApp } = useTelegramWebApp();
const { impact: hapticImpact } = useHaptic();
const isMobileScreen = useIsMobile();
const isMobile = isMobileScreen;
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Ref для хранения актуального обработчика BackButton (фикс мигания)
@@ -171,26 +149,6 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
const hapticRef = useRef(hapticImpact);
hapticRef.current = hapticImpact;
// Prevent scroll events from bubbling to parent/Telegram
const handleScrollContainerWheel = useCallback((e: React.WheelEvent) => {
const container = e.currentTarget;
const { scrollTop, scrollHeight, clientHeight } = container;
const isAtTop = scrollTop === 0;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1;
// Prevent scroll propagation when not at boundaries, or when scrolling away from boundary
if ((!isAtTop && !isAtBottom) || (isAtTop && e.deltaY > 0) || (isAtBottom && e.deltaY < 0)) {
e.stopPropagation();
}
}, []);
const safeBottom = isTelegramWebApp
? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom)
: 0;
const safeTop = isTelegramWebApp
? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + (isFullscreen ? 45 : 0)
: 0;
const {
data: appConfig,
isLoading,
@@ -214,9 +172,9 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
if (app) setSelectedApp(app);
}, [appConfig, detectedPlatform, selectedApp]);
const handleClose = useCallback(() => {
onClose();
}, [onClose]);
const handleGoBack = useCallback(() => {
navigate(-1);
}, [navigate]);
const handleBack = useCallback(() => {
if (selectedPlatform) {
@@ -231,17 +189,17 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
if (e.key === 'Escape') {
e.preventDefault();
if (showAppSelector) handleBack();
else handleClose();
else handleGoBack();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleClose, handleBack, showAppSelector]);
}, [handleGoBack, handleBack, showAppSelector]);
// Обновляем ref при изменении логики (без перезапуска эффекта BackButton)
useEffect(() => {
backButtonHandlerRef.current = showAppSelector ? handleBack : handleClose;
}, [showAppSelector, handleBack, handleClose]);
backButtonHandlerRef.current = showAppSelector ? handleBack : handleGoBack;
}, [showAppSelector, handleBack, handleGoBack]);
// BackButton using platform hook - always close/back, ref provides current handler
const handleBackButton = useCallback(() => {
@@ -251,13 +209,6 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
useBackButton(handleBackButton);
useEffect(() => {
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = '';
};
}, []);
const getLocalizedText = (text: LocalizedText | undefined): string => {
if (!text) return '';
const lang = i18n.language || 'en';
@@ -273,6 +224,18 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
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;
return resolveTemplate(url, {
subscriptionUrl: appConfig.subscriptionUrl,
username: user?.username ?? undefined,
});
},
[appConfig?.subscriptionUrl, user?.username],
);
const copySubscriptionLink = async () => {
if (!appConfig?.subscriptionUrl) return;
try {
@@ -293,12 +256,25 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
const handleConnect = (app: AppInfo) => {
if (!app.deepLink || !isValidDeepLink(app.deepLink)) return;
const deepLink = app.deepLink;
let deepLink = app.deepLink;
// All deep links (including custom schemes like happ://, clash://, etc.)
// go through redirect.html. Telegram's openLink opens the redirect page
// in an external browser, which then performs window.location.href to the
// actual deep link URL. This works for both http(s) and custom schemes.
// Resolve templates if present
if (hasTemplates(deepLink)) {
deepLink = resolveUrl(deepLink);
}
// On mobile (iOS/Android) apps handle URL schemes natively —
// open the deep link directly without the redirect.html intermediary.
const isMobilePlatform = detectedPlatform === 'ios' || detectedPlatform === 'android';
if (isMobilePlatform) {
window.location.href = deepLink;
return;
}
// Desktop platforms (macOS, Windows, Linux) and others go through
// redirect.html. Telegram's openLink opens the redirect page in an
// external browser, which then performs window.location.href to the
// actual deep link URL.
const redirectUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(deepLink)}&lang=${i18n.language || 'en'}`;
try {
@@ -310,79 +286,44 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
window.location.href = redirectUrl;
};
// Wrapper component
const Wrapper = ({ children }: { children: React.ReactNode }) => {
if (isMobile) {
const content = (
<div
className="fixed inset-0 z-[9999] flex flex-col bg-dark-900"
style={{
paddingTop: safeTop ? `${safeTop}px` : 'env(safe-area-inset-top, 0px)',
paddingBottom: safeBottom ? `${safeBottom}px` : 'env(safe-area-inset-bottom, 0px)',
}}
>
{children}
</div>
);
if (typeof document !== 'undefined') return createPortal(content, document.body);
return content;
}
// Desktop centered - positioned higher
return (
<div
className="fixed inset-0 z-[60] flex items-start justify-center p-4 pt-[8vh]"
onClick={handleClose}
>
<div
className="relative flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-900 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
);
// Resolve button links for step buttons
const resolveButtonLink = (link: string): string => {
return resolveUrl(link);
};
// Loading
if (isLoading) {
return (
<Wrapper>
<div className="flex flex-1 items-center justify-center">
<div className="h-10 w-10 animate-spin rounded-full border-[3px] border-accent-500/30 border-t-accent-500" />
</div>
</Wrapper>
<div className="flex flex-1 items-center justify-center py-20">
<div className="h-10 w-10 animate-spin rounded-full border-[3px] border-accent-500/30 border-t-accent-500" />
</div>
);
}
// Error
if (error || !appConfig) {
return (
<Wrapper>
<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>
<button onClick={handleClose} className="btn-primary px-6 py-2">
{t('common.close')}
</button>
</div>
</Wrapper>
<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>
<button onClick={handleGoBack} className="btn-primary px-6 py-2">
{t('common.close')}
</button>
</div>
);
}
// No subscription
if (!appConfig.hasSubscription) {
return (
<Wrapper>
<div className="flex flex-1 flex-col items-center justify-center p-8 text-center">
<h3 className="mb-2 text-xl font-bold text-dark-100">
{t('subscription.connection.title')}
</h3>
<p className="mb-4 text-dark-400">{t('subscription.connection.noSubscription')}</p>
<button onClick={handleClose} className="btn-primary px-6 py-2">
{t('common.close')}
</button>
</div>
</Wrapper>
<div className="flex flex-1 flex-col items-center justify-center p-8 text-center">
<h3 className="mb-2 text-xl font-bold text-dark-100">
{t('subscription.connection.title')}
</h3>
<p className="mb-4 text-dark-400">{t('subscription.connection.noSubscription')}</p>
<button onClick={handleGoBack} className="btn-primary px-6 py-2">
{t('common.close')}
</button>
</div>
);
}
@@ -439,8 +380,8 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
// Step 1: Platform selection
if (!selectedPlatform) {
return (
<Wrapper>
<div className="flex items-center gap-3 border-b border-dark-800 p-4">
<div className="space-y-4">
<div className="flex items-center gap-3">
{!isTelegramWebApp && (
<button
onClick={handleBack}
@@ -453,7 +394,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
{t('subscription.connection.selectPlatform')}
</h2>
</div>
<div className="space-y-2 p-4">
<div className="space-y-2">
{availablePlatforms.map((platform) => {
const apps = appConfig.platforms[platform];
if (!apps?.length) return null;
@@ -504,7 +445,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
);
})}
</div>
</Wrapper>
</div>
);
}
@@ -513,8 +454,8 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
const isCurrentPlatform = selectedPlatform === detectedPlatform;
return (
<Wrapper>
<div className="flex items-center gap-3 border-b border-dark-800 p-4">
<div className="space-y-4">
<div className="flex items-center gap-3">
{!isTelegramWebApp && (
<button
onClick={handleBack}
@@ -534,12 +475,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
)}
</div>
</div>
<div
ref={scrollContainerRef}
className={`${isMobile ? 'flex-1' : 'max-h-[60vh]'} overflow-y-auto p-4`}
style={{ overscrollBehavior: 'contain', WebkitOverflowScrolling: 'touch' }}
onWheel={handleScrollContainerWheel}
>
<div ref={scrollContainerRef}>
<div className="grid grid-cols-3 gap-3">
{apps.map((app) => {
const isSelected = selectedApp?.id === app.id;
@@ -596,22 +532,22 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
})}
</div>
</div>
</Wrapper>
</div>
);
}
// Main view
return (
<Wrapper>
<div className="border-b border-dark-800 p-4">
<div className="space-y-6">
<div>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-bold text-dark-100">{t('subscription.connection.title')}</h2>
{!isTelegramWebApp && (
<button
onClick={handleClose}
onClick={handleGoBack}
className="-mr-2 rounded-xl p-2 text-dark-400 hover:bg-dark-800"
>
<CloseIcon />
<BackIcon />
</button>
)}
</div>
@@ -630,11 +566,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
</button>
</div>
<div
className={`${isMobile ? 'flex-1' : 'max-h-[50vh]'} space-y-4 overflow-y-auto p-4`}
style={{ overscrollBehavior: 'contain', WebkitOverflowScrolling: 'touch' }}
onWheel={handleScrollContainerWheel}
>
<div className="space-y-4">
{selectedApp?.installationStep && (
<div className="space-y-2">
<div className="flex items-center gap-2">
@@ -652,11 +584,11 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
selectedApp.installationStep.buttons.length > 0 && (
<div className="ml-8 flex flex-wrap gap-2">
{selectedApp.installationStep.buttons
.filter((btn) => isValidExternalUrl(btn.buttonLink))
.filter((btn) => isValidExternalUrl(resolveButtonLink(btn.buttonLink)))
.map((btn, idx) => (
<a
key={idx}
href={btn.buttonLink}
href={resolveButtonLink(btn.buttonLink)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg bg-dark-800 px-3 py-1.5 text-sm text-dark-200 hover:bg-dark-700"
@@ -728,6 +660,6 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
</div>
)}
</div>
</Wrapper>
</div>
);
}

View File

@@ -1,13 +1,12 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/auth';
import { subscriptionApi } from '../api/subscription';
import { referralApi } from '../api/referral';
import { balanceApi } from '../api/balance';
import { wheelApi } from '../api/wheel';
import ConnectionModal from '../components/ConnectionModal';
import Onboarding, { useOnboarding } from '../components/Onboarding';
import PromoOffersSection from '../components/PromoOffersSection';
import { useCurrency } from '../hooks/useCurrency';
@@ -49,9 +48,9 @@ export default function Dashboard() {
const { t } = useTranslation();
const { user, refreshUser } = useAuthStore();
const queryClient = useQueryClient();
const navigate = useNavigate();
const { formatAmount, currencySymbol, formatPositive } = useCurrency();
const [trialError, setTrialError] = useState<string | null>(null);
const [showConnectionModal, setShowConnectionModal] = useState(false);
const { isCompleted: isOnboardingCompleted, complete: completeOnboarding } = useOnboarding();
const [showOnboarding, setShowOnboarding] = useState(false);
@@ -410,7 +409,7 @@ export default function Dashboard() {
</Link>
{subscription.subscription_url && (
<button
onClick={() => setShowConnectionModal(true)}
onClick={() => navigate('/connection')}
className="btn-secondary py-2.5 text-sm"
data-onboarding="connect-devices"
>
@@ -635,9 +634,6 @@ export default function Dashboard() {
</Link>
)}
{/* Connection Modal */}
{showConnectionModal && <ConnectionModal onClose={() => setShowConnectionModal(false)} />}
{/* Onboarding Tutorial */}
{showOnboarding && (
<Onboarding

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { AxiosError } from 'axios';
import { subscriptionApi } from '../api/subscription';
import { promoApi } from '../api/promo';
@@ -12,7 +12,6 @@ import type {
TariffPeriod,
ClassicPurchaseOptions,
} from '../types';
import ConnectionModal from '../components/ConnectionModal';
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
import { useCurrency } from '../hooks/useCurrency';
import { useCloseOnSuccessNotification } from '../store/successNotification';
@@ -83,9 +82,9 @@ export default function Subscription() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const location = useLocation();
const navigate = useNavigate();
const { formatAmount, currencySymbol } = useCurrency();
const [copied, setCopied] = useState(false);
const [showConnectionModal, setShowConnectionModal] = useState(false);
// Helper to format price from kopeks
const formatPrice = (kopeks: number) => `${formatAmount(kopeks / 100)} ${currencySymbol}`;
@@ -293,7 +292,6 @@ export default function Subscription() {
// Auto-close all modals/forms when success notification appears (e.g., subscription purchased via WebSocket)
const handleCloseAllModals = useCallback(() => {
setShowConnectionModal(false);
setShowPurchaseForm(false);
setShowTariffPurchase(false);
setShowDeviceTopup(false);
@@ -721,7 +719,7 @@ export default function Subscription() {
{/* Get Config Button */}
<button
onClick={() => setShowConnectionModal(true)}
onClick={() => navigate('/connection')}
className="btn-primary mb-3 flex w-full items-center justify-center gap-2 py-3"
>
<svg
@@ -3501,9 +3499,6 @@ export default function Subscription() {
)}
</div>
)}
{/* Connection Modal */}
{showConnectionModal && <ConnectionModal onClose={() => setShowConnectionModal(false)} />}
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { createHappCryptoLink } from '@kastov/cryptohapp';
const TEMPLATE_RE = /\{\{[A-Z0-9_]+\}\}/;
export function hasTemplates(url: string): boolean {
return TEMPLATE_RE.test(url);
}
interface ResolveContext {
subscriptionUrl: string;
username?: string;
}
export function resolveTemplate(template: string, ctx: ResolveContext): string {
let result = template;
result = result.replace(/\{\{SUBSCRIPTION_LINK\}\}/g, ctx.subscriptionUrl);
if (ctx.username) {
result = result.replace(/\{\{USERNAME\}\}/g, ctx.username);
}
result = result.replace(/\{\{HAPP_CRYPT3_LINK\}\}/g, () => {
return createHappCryptoLink(ctx.subscriptionUrl, 'v3', true) ?? ctx.subscriptionUrl;
});
result = result.replace(/\{\{HAPP_CRYPT4_LINK\}\}/g, () => {
return createHappCryptoLink(ctx.subscriptionUrl, 'v4', true) ?? ctx.subscriptionUrl;
});
return result;
}