From 46b93ef098798e626ea8504ceac32f4736f3ea65 Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 5 Feb 2026 06:26:27 +0300 Subject: [PATCH 01/35] fix(subscription): display promo group discounts in device/traffic purchase and tariff switch - Add discount fields to TrafficPackage and TariffSwitchPreview types - Show strikethrough original price and discount badge for device purchase - Show discount badge and original price for traffic packages - Show discount info and free label for tariff switch with 100% discount --- src/api/subscription.ts | 9 ++++ src/pages/Subscription.tsx | 103 ++++++++++++++++++++++++++++++++----- src/types/index.ts | 8 +++ 3 files changed, 106 insertions(+), 14 deletions(-) diff --git a/src/api/subscription.ts b/src/api/subscription.ts index 868d517..99fe63d 100644 --- a/src/api/subscription.ts +++ b/src/api/subscription.ts @@ -91,6 +91,11 @@ export const subscriptionApi = { can_add?: number; days_left?: number; base_device_price_kopeks?: number; + // Discount fields (from promo group) + original_price_per_device_kopeks?: number; + base_total_price_kopeks?: number; + discount_percent?: number; + discount_kopeks?: number; }> => { const response = await apiClient.get('/cabinet/subscription/devices/price', { params: { devices }, @@ -355,6 +360,10 @@ export const subscriptionApi = { missing_amount_kopeks: number; missing_amount_label: string; is_upgrade: boolean; + // Discount fields (from promo group) + base_upgrade_cost_kopeks?: number; + discount_percent?: number; + discount_kopeks?: number; }> => { const response = await apiClient.post('/cabinet/subscription/tariff/switch/preview', { tariff_id: tariffId, diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 1f8e048..9be84c7 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -1215,13 +1215,47 @@ export default function Subscription() { {devicePriceData?.available && devicePriceData.price_per_device_label && (
- {devicePriceData.price_per_device_label}/ - {t('subscription.perDevice').replace('/ ', '')} ( + {/* Show original price with strikethrough if discount */} + {devicePriceData.discount_percent && + devicePriceData.discount_percent > 0 ? ( + + + {formatPrice(devicePriceData.original_price_per_device_kopeks || 0)} + + {devicePriceData.price_per_device_label} + + ) : ( + devicePriceData.price_per_device_label + )} + /{t('subscription.perDevice').replace('/ ', '')} ( {t('subscription.days', { count: devicePriceData.days_left })})
-
- {devicePriceData.total_price_label} -
+ {/* Discount badge */} + {devicePriceData.discount_percent && devicePriceData.discount_percent > 0 && ( +
+ + -{devicePriceData.discount_percent}% + +
+ )} + {/* Total price - show as free if 100% discount or 0 */} + {devicePriceData.total_price_kopeks === 0 ? ( +
+ {t('subscription.switchTariff.free')} +
+ ) : ( +
+ {/* Show original total with strikethrough if discount */} + {devicePriceData.discount_percent && + devicePriceData.discount_percent > 0 && + devicePriceData.base_total_price_kopeks && ( + + {formatPrice(devicePriceData.base_total_price_kopeks)} + + )} + {devicePriceData.total_price_label} +
+ )}
)} @@ -1514,8 +1548,28 @@ export default function Subscription() { ? '♾️ ' + t('subscription.additionalOptions.unlimited') : `${pkg.gb} ${t('common.units.gb')}`} + {/* Discount badge */} + {pkg.discount_percent && pkg.discount_percent > 0 && ( +
+ + -{pkg.discount_percent}% + +
+ )} + {/* Price with original strikethrough if discount */}
- {formatPrice(pkg.price_kopeks)} + {pkg.discount_percent && + pkg.discount_percent > 0 && + pkg.base_price_kopeks ? ( + <> + + {formatPrice(pkg.base_price_kopeks)} + + {formatPrice(pkg.price_kopeks)} + + ) : ( + formatPrice(pkg.price_kopeks) + )}
))} @@ -2102,14 +2156,35 @@ export default function Subscription() { )}
- - {t('subscription.switchTariff.upgradeCost')} - - - {switchPreview.upgrade_cost_kopeks > 0 - ? switchPreview.upgrade_cost_label - : t('subscription.switchTariff.free')} - +
+ + {t('subscription.switchTariff.upgradeCost')} + + {/* Discount badge */} + {switchPreview.discount_percent && switchPreview.discount_percent > 0 && ( + + -{switchPreview.discount_percent}% + + )} +
+
+ {/* Show original price with strikethrough if discount */} + {switchPreview.discount_percent && + switchPreview.discount_percent > 0 && + switchPreview.base_upgrade_cost_kopeks && + switchPreview.base_upgrade_cost_kopeks > 0 && ( + + {formatPrice(switchPreview.base_upgrade_cost_kopeks)} + + )} + + {switchPreview.upgrade_cost_kopeks > 0 + ? switchPreview.upgrade_cost_label + : t('subscription.switchTariff.free')} + +
{!switchPreview.has_enough_balance && diff --git a/src/types/index.ts b/src/types/index.ts index 0d9e1ff..0019b42 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -122,6 +122,10 @@ export interface TariffSwitchPreview { missing_amount_kopeks: number; missing_amount_label: string; is_upgrade: boolean; + // Discount fields (from promo group) + base_upgrade_cost_kopeks?: number; + discount_percent?: number; + discount_kopeks?: number; } export interface RenewalOption { @@ -137,6 +141,10 @@ export interface TrafficPackage { price_kopeks: number; price_rubles: number; is_unlimited: boolean; + // Discount fields (from promo group) + base_price_kopeks?: number; + discount_percent?: number; + discount_kopeks?: number; } export interface TrialInfo { From 998f9dbaf0ea9c3ae28ece77b4906e0f6e8f704f Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 5 Feb 2026 07:32:28 +0300 Subject: [PATCH 02/35] feat(subscription): auto-skip server selection step when only one available - Add getAvailableServers helper to filter available servers - Skip servers step in purchase wizard when only 1 server available - Auto-select the single server on options load and period change - Works for both new purchases and renewals in classic mode --- src/pages/Subscription.tsx | 52 +++++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 9be84c7..c067590 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -175,16 +175,28 @@ export default function Subscription() { const tariffs = isTariffsMode && purchaseOptions && 'tariffs' in purchaseOptions ? purchaseOptions.tariffs : []; + // Get truly available servers for a given period (same filter as rendering) + const getAvailableServers = useCallback( + (period: PeriodOption | null) => { + if (!period?.servers.options) return []; + return period.servers.options.filter((server) => { + if (!server.is_available) return false; + if (subscription?.is_trial && server.name.toLowerCase().includes('trial')) return false; + return true; + }); + }, + [subscription?.is_trial], + ); + // Determine which steps are needed const steps = useMemo(() => { const result: PurchaseStep[] = ['period']; if (selectedPeriod?.traffic.selectable && (selectedPeriod.traffic.options?.length ?? 0) > 0) { result.push('traffic'); } - if ( - selectedPeriod && - (selectedPeriod.servers.options?.filter((s) => s.is_available).length ?? 0) > 0 - ) { + const availableServers = getAvailableServers(selectedPeriod); + // Skip server selection step if only 1 server available (auto-select it) + if (availableServers.length > 1) { result.push('servers'); } if (selectedPeriod && selectedPeriod.devices.max > selectedPeriod.devices.min) { @@ -192,7 +204,7 @@ export default function Subscription() { } result.push('confirm'); return result; - }, [selectedPeriod]); + }, [selectedPeriod, getAvailableServers]); const currentStepIndex = steps.indexOf(currentStep); const isFirstStep = currentStepIndex === 0; @@ -206,15 +218,19 @@ export default function Subscription() { classicOptions.periods[0]; setSelectedPeriod(defaultPeriod); setSelectedTraffic(classicOptions.selection.traffic_value); - const availableServerUuids = new Set( - defaultPeriod.servers.options?.filter((s) => s.is_available).map((s) => s.uuid) ?? [], - ); - setSelectedServers( - classicOptions.selection.servers.filter((uuid) => availableServerUuids.has(uuid)), - ); + const availableServers = getAvailableServers(defaultPeriod); + const availableServerUuids = new Set(availableServers.map((s) => s.uuid)); + // If only 1 server available, auto-select it (step will be skipped) + if (availableServers.length === 1) { + setSelectedServers([availableServers[0].uuid]); + } else { + setSelectedServers( + classicOptions.selection.servers.filter((uuid) => availableServerUuids.has(uuid)), + ); + } setSelectedDevices(classicOptions.selection.devices); } - }, [classicOptions, selectedPeriod]); + }, [classicOptions, selectedPeriod, getAvailableServers]); // Build selection object const currentSelection: PurchaseSelection = useMemo( @@ -3193,12 +3209,12 @@ export default function Subscription() { if (period.traffic.current !== undefined) { setSelectedTraffic(period.traffic.current); } - if (period.servers.selected) { - const availUuids = new Set( - period.servers.options - ?.filter((s) => s.is_available) - .map((s) => s.uuid) ?? [], - ); + const availableServers = getAvailableServers(period); + // If only 1 server available, auto-select it (step will be skipped) + if (availableServers.length === 1) { + setSelectedServers([availableServers[0].uuid]); + } else if (period.servers.selected) { + const availUuids = new Set(availableServers.map((s) => s.uuid)); setSelectedServers( period.servers.selected.filter((uuid) => availUuids.has(uuid)), ); From 445dd0601a0a262d12a3329829516b9beb43693a Mon Sep 17 00:00:00 2001 From: c0mrade Date: Thu, 5 Feb 2026 07:33:45 +0300 Subject: [PATCH 03/35] 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 --- package-lock.json | 24 +- package.json | 2 + src/App.tsx | 11 + src/api/adminApps.ts | 87 +- src/components/admin/ThemeTab.tsx | 232 +++- src/pages/AdminApps.tsx | 1155 ++++------------- .../Connection.tsx} | 230 ++-- src/pages/Dashboard.tsx | 10 +- src/pages/Subscription.tsx | 11 +- src/utils/templateEngine.ts | 32 + 10 files changed, 580 insertions(+), 1214 deletions(-) rename src/{components/ConnectionModal.tsx => pages/Connection.tsx} (82%) create mode 100644 src/utils/templateEngine.ts diff --git a/package-lock.json b/package-lock.json index c3b1b0e..73d9f3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@kastov/cryptohapp": "^1.1.2", "@lottiefiles/dotlottie-react": "^0.8.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", @@ -33,6 +34,7 @@ "framer-motion": "^12.29.2", "i18next": "^23.7.0", "i18next-browser-languagedetector": "^7.2.0", + "jsencrypt": "^3.5.4", "ogl": "^1.0.11", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -1182,6 +1184,20 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kastov/cryptohapp": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@kastov/cryptohapp/-/cryptohapp-1.1.2.tgz", + "integrity": "sha512-vwBLWmKJ6g8opVfvzFPZxX3U65ncXawsasqlO5PxVvpejl3MgkpveUkOMoec6gNuGgSSjjxv4ydFH0h3qz3gBw==", + "license": "MIT", + "peerDependencies": { + "jsencrypt": ">=3" + }, + "peerDependenciesMeta": { + "jsencrypt": { + "optional": true + } + } + }, "node_modules/@lottiefiles/dotlottie-react": { "version": "0.8.12", "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.8.12.tgz", @@ -4702,6 +4718,13 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsencrypt": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/jsencrypt/-/jsencrypt-3.5.4.tgz", + "integrity": "sha512-kNjfYEMNASxrDGsmcSQh/rUTmcoRfSUkxnAz+MMywM8jtGu+fFEZ3nJjHM58zscVnwR0fYmG9sGkTDjqUdpiwA==", + "license": "MIT", + "peer": true + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5705,7 +5728,6 @@ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", "license": "MIT", - "peer": true, "dependencies": { "@remix-run/router": "1.23.2", "react-router": "6.30.3" diff --git a/package.json b/package.json index 21eeeeb..f7bd6cb 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@kastov/cryptohapp": "^1.1.2", "@lottiefiles/dotlottie-react": "^0.8.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", @@ -41,6 +42,7 @@ "framer-motion": "^12.29.2", "i18next": "^23.7.0", "i18next-browser-languagedetector": "^7.2.0", + "jsencrypt": "^3.5.4", "ogl": "^1.0.11", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/src/App.tsx b/src/App.tsx index 28eda68..7cd8f0f 100644 --- a/src/App.tsx +++ b/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() { } /> + + + + + + } + /> {/* Admin routes */} => { - const response = await apiClient.get('/cabinet/admin/apps'); - return response.data; - }, - - // Get available platforms - getPlatforms: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/apps/platforms'); - return response.data; - }, - - // Get apps for a platform - getPlatformApps: async (platform: string): Promise => { - const response = await apiClient.get( - `/cabinet/admin/apps/platforms/${platform}`, - ); - return response.data; - }, - - // Create a new app - createApp: async (platform: string, app: AppDefinition): Promise => { - const response = await apiClient.post( - `/cabinet/admin/apps/platforms/${platform}`, - { - platform, - app, - }, - ); - return response.data; - }, - - // Update an app - updateApp: async ( - platform: string, - appId: string, - app: AppDefinition, - ): Promise => { - const response = await apiClient.put( - `/cabinet/admin/apps/platforms/${platform}/${appId}`, - { - app, - }, - ); - return response.data; - }, - - // Delete an app - deleteApp: async (platform: string, appId: string): Promise => { - await apiClient.delete(`/cabinet/admin/apps/platforms/${platform}/${appId}`); - }, - - // Reorder apps - reorderApps: async (platform: string, appIds: string[]): Promise => { - 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 => { - const response = await apiClient.get('/cabinet/admin/apps/branding'); - return response.data; - }, - - // Update branding - updateBranding: async (branding: AppConfigBranding): Promise => { - const response = await apiClient.put('/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 { diff --git a/src/components/admin/ThemeTab.tsx b/src/components/admin/ThemeTab.tsx index e31a59a..380d851 100644 --- a/src/components/admin/ThemeTab.tsx +++ b/src/components/admin/ThemeTab.tsx @@ -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(DEFAULT_THEME_COLORS); + const savedColorsRef = useRef(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) => { + 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) => ( + {/* Save / Cancel / Reset buttons */} +
+ {hasUnsavedChanges && ( + <> + + + + )} + +
)} diff --git a/src/pages/AdminApps.tsx b/src/pages/AdminApps.tsx index 8ed24d2..e965c1a 100644 --- a/src/pages/AdminApps.tsx +++ b/src/pages/AdminApps.tsx @@ -1,17 +1,17 @@ -import { useState, useRef } from 'react'; +import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; +import DOMPurify from 'dompurify'; import { adminAppsApi, - AppDefinition, LocalizedText, - AppStep, - AppButton, - exportToRemnawaveFormat, - importFromRemnawaveFormat, - RemnawaveConfig, - importFromRemnawaveFormat as convertRemnawave, + RemnawaveApp, + RemnawaveSvgItem, + RemnawaveBlock, + RemnawaveButton, + RemnawaveBaseSettings, + RemnawaveBaseTranslations, } from '../api/adminApps'; import { useBackButton } from '../platform/hooks/useBackButton'; import { usePlatform } from '../platform/hooks/usePlatform'; @@ -40,54 +40,6 @@ const AppsIcon = () => ( ); -const PlusIcon = () => ( - - - -); - -const EditIcon = () => ( - - - -); - -const TrashIcon = () => ( - - - -); - -const ChevronUpIcon = () => ( - - - -); - -const ChevronDownIcon = () => ( - - - -); - -const CopyIcon = () => ( - - - -); - const StarIcon = ({ filled }: { filled: boolean }) => ( ( ); -const DownloadIcon = () => ( - - - -); - -const UploadIcon = () => ( - - - -); - const CloudSyncIcon = () => ( ( ); +const ChevronDownIcon = () => ( + + + +); + const PLATFORM_LABELS: Record = { ios: 'iOS', android: 'Android', @@ -166,417 +104,221 @@ const PLATFORM_LABELS: Record = { const PLATFORMS = ['ios', 'android', 'macos', 'windows', 'linux', 'androidTV', 'appleTV']; -// Helper to create empty localized text -const emptyLocalizedText = (): LocalizedText => ({ - en: '', - ru: '', - zh: '', - fa: '', -}); - -// Helper to create empty app step -const emptyAppStep = (): AppStep => ({ - description: emptyLocalizedText(), - buttons: [], -}); - -// Helper to create empty app -const createEmptyApp = (platform: string): AppDefinition => ({ - id: `new-app-${platform}-${Date.now()}`, - name: '', - isFeatured: false, - urlScheme: '', - installationStep: emptyAppStep(), - addSubscriptionStep: emptyAppStep(), - connectAndUseStep: emptyAppStep(), -}); - -interface AppEditorModalProps { - app: AppDefinition; - platform: string; - isNew: boolean; - onSave: (app: AppDefinition) => void; - onClose: () => void; +function getLocalizedText(text: LocalizedText | undefined, lang: string): string { + if (!text) return ''; + return ( + text[lang as keyof LocalizedText] || + text['en'] || + text['ru'] || + Object.values(text).find((v) => v) || + '' + ); } -function AppEditorModal({ app, platform, isNew, onSave, onClose }: AppEditorModalProps) { +function renderSvgIcon(svgLibrary: Record, key?: string, color?: string) { + if (!key || !svgLibrary[key]) return null; + const sanitized = DOMPurify.sanitize(svgLibrary[key].svgString, { + USE_PROFILES: { svg: true, svgFilters: true }, + }); + return ( +
+ ); +} + +function BaseSettingsPanel({ settings }: { settings: RemnawaveBaseSettings }) { const { t } = useTranslation(); - const [editedApp, setEditedApp] = useState({ ...app }); - const [activeTab, setActiveTab] = useState< - 'basic' | 'installation' | 'subscription' | 'connect' | 'additional' - >('basic'); - - const updateField = (field: K, value: AppDefinition[K]) => { - setEditedApp((prev) => ({ ...prev, [field]: value })); - }; - - const updateLocalizedText = ( - stepKey: - | 'installationStep' - | 'addSubscriptionStep' - | 'connectAndUseStep' - | 'additionalBeforeAddSubscriptionStep' - | 'additionalAfterAddSubscriptionStep', - field: 'description' | 'title', - lang: keyof LocalizedText, - value: string, - ) => { - setEditedApp((prev) => { - const step = prev[stepKey] || emptyAppStep(); - const fieldValue = step[field] || emptyLocalizedText(); - return { - ...prev, - [stepKey]: { - ...step, - [field]: { ...fieldValue, [lang]: value }, - }, - }; - }); - }; - - const updateButtons = ( - stepKey: - | 'installationStep' - | 'addSubscriptionStep' - | 'connectAndUseStep' - | 'additionalBeforeAddSubscriptionStep' - | 'additionalAfterAddSubscriptionStep', - buttons: AppButton[], - ) => { - setEditedApp((prev) => { - const step = prev[stepKey] || emptyAppStep(); - return { - ...prev, - [stepKey]: { ...step, buttons }, - }; - }); - }; - - const addButton = ( - stepKey: - | 'installationStep' - | 'additionalBeforeAddSubscriptionStep' - | 'additionalAfterAddSubscriptionStep', - ) => { - const step = editedApp[stepKey] || emptyAppStep(); - const buttons = step.buttons || []; - // Generate unique id for React key - const newButton: AppButton = { - id: `btn-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, - buttonLink: '', - buttonText: emptyLocalizedText(), - }; - updateButtons(stepKey, [...buttons, newButton]); - }; - - const removeButton = ( - stepKey: - | 'installationStep' - | 'additionalBeforeAddSubscriptionStep' - | 'additionalAfterAddSubscriptionStep', - index: number, - ) => { - const step = editedApp[stepKey] || emptyAppStep(); - const buttons = step.buttons || []; - updateButtons( - stepKey, - buttons.filter((_, i) => i !== index), - ); - }; - - const updateButton = ( - stepKey: - | 'installationStep' - | 'additionalBeforeAddSubscriptionStep' - | 'additionalAfterAddSubscriptionStep', - index: number, - field: 'buttonLink' | 'buttonText', - value: string | LocalizedText, - ) => { - const step = editedApp[stepKey] || emptyAppStep(); - const buttons = [...(step.buttons || [])]; - buttons[index] = { ...buttons[index], [field]: value }; - updateButtons(stepKey, buttons); - }; - - const renderLocalizedTextInputs = ( - stepKey: - | 'installationStep' - | 'addSubscriptionStep' - | 'connectAndUseStep' - | 'additionalBeforeAddSubscriptionStep' - | 'additionalAfterAddSubscriptionStep', - field: 'description' | 'title', - label: string, - ) => { - const step = editedApp[stepKey]; - const value = step?.[field] || emptyLocalizedText(); - - return ( -
- - {(['en', 'ru'] as const).map((lang) => ( -
- {lang} -