mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
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:
11
src/App.tsx
11
src/App.tsx
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
32
src/utils/templateEngine.ts
Normal file
32
src/utils/templateEngine.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user