Merge pull request #51 from BEDOLAGA-DEV/main

ц
This commit is contained in:
Egor
2026-01-20 04:27:12 +03:00
committed by GitHub
10 changed files with 764 additions and 436 deletions

View File

@@ -11,6 +11,10 @@ export interface AnimationEnabled {
enabled: boolean
}
export interface FullscreenEnabled {
enabled: boolean
}
const BRANDING_CACHE_KEY = 'cabinet_branding'
const LOGO_PRELOADED_KEY = 'cabinet_logo_preloaded'
@@ -121,4 +125,21 @@ export const brandingApi = {
const response = await apiClient.patch<AnimationEnabled>('/cabinet/branding/animation', { enabled })
return response.data
},
// Get fullscreen enabled (public, no auth required)
getFullscreenEnabled: async (): Promise<FullscreenEnabled> => {
try {
const response = await apiClient.get<FullscreenEnabled>('/cabinet/branding/fullscreen')
return response.data
} catch {
// If endpoint doesn't exist, default to disabled
return { enabled: false }
}
},
// Update fullscreen enabled (admin only)
updateFullscreenEnabled: async (enabled: boolean): Promise<FullscreenEnabled> => {
const response = await apiClient.patch<FullscreenEnabled>('/cabinet/branding/fullscreen', { enabled })
return response.data
},
}

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useEffect } from 'react'
import { useState, useMemo, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query'
import { subscriptionApi } from '../api/subscription'
@@ -10,40 +10,75 @@ interface ConnectionModalProps {
// Platform SVG Icons
const IosIcon = () => (
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="currentColor">
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/>
</svg>
)
const AndroidIcon = () => (
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="currentColor">
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
<path d="M17.6 9.48l1.84-3.18c.16-.31.04-.69-.26-.85-.29-.15-.65-.06-.83.22l-1.88 3.24a11.463 11.463 0 00-8.94 0L5.65 5.67c-.19-.29-.58-.38-.87-.2-.28.18-.37.54-.22.83L6.4 9.48A10.78 10.78 0 003 18h18a10.78 10.78 0 00-3.4-8.52zM7 15.25a1.25 1.25 0 110-2.5 1.25 1.25 0 010 2.5zm10 0a1.25 1.25 0 110-2.5 1.25 1.25 0 010 2.5z"/>
</svg>
)
const WindowsIcon = () => (
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="currentColor">
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 12V6.75l6-1.32v6.48L3 12zm17-9v8.75l-10 .15V5.21L20 3zM3 13l6 .09v6.81l-6-1.15V13zm17 .25V22l-10-1.91V13.1l10 .15z"/>
</svg>
)
const MacosIcon = () => (
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 4h16a2 2 0 012 2v10a2 2 0 01-2 2h-6v2h2a1 1 0 110 2H8a1 1 0 110-2h2v-2H4a2 2 0 01-2-2V6a2 2 0 012-2zm0 2v10h16V6H4zm8 2.5a1 1 0 011 1v3a1 1 0 11-2 0v-3a1 1 0 011-1z"/>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 4h16a2 2 0 012 2v10a2 2 0 01-2 2h-6v2h2a1 1 0 110 2H8a1 1 0 110-2h2v-2H4a2 2 0 01-2-2V6a2 2 0 012-2zm0 2v10h16V6H4z"/>
</svg>
)
const LinuxIcon = () => (
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="currentColor">
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C9.5 2 8 4.5 8 7c0 1.5.5 3 1 4-1.5 1-3 3-3 5 0 .5 0 1 .5 1.5-.5.5-1.5 1-1.5 2 0 1.5 2 2.5 4 2.5h6c2 0 4-1 4-2.5 0-1-1-1.5-1.5-2 .5-.5.5-1 .5-1.5 0-2-1.5-4-3-5 .5-1 1-2.5 1-4 0-2.5-1.5-5-4-5zm-2 5c.5 0 1 .5 1 1s-.5 1-1 1-1-.5-1-1 .5-1 1-1zm4 0c.5 0 1 .5 1 1s-.5 1-1 1-1-.5-1-1 .5-1 1-1zm-2 3c1 0 2 .5 2 1s-1 1-2 1-2-.5-2-1 1-1 2-1z"/>
</svg>
)
const TvIcon = () => (
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="20" height="13" rx="2" ry="2"/>
<polyline points="8 21 12 17 16 21"/>
<line x1="12" y1="17" x2="12" y2="13"/>
</svg>
)
const CloseIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)
const BackIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
)
const CopyIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)
const CheckIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)
const LinkIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
)
const ChevronIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
)
@@ -58,107 +93,185 @@ const platformIconComponents: Record<string, React.FC> = {
appleTV: TvIcon,
}
// App icons
const HappIcon = () => (
<svg className="w-6 h-6" viewBox="0 0 50 50" fill="currentColor">
<path d="M22.3264 3H12.3611L9.44444 20.1525L21.3542 8.22034L22.3264 3Z"/>
<path d="M10.9028 20.1525L22.8125 8.22034L20.8681 21.1469H28.4028L27.9167 21.6441L20.8681 28.8531H19.4097V30.5932L7.5 42.5254L10.9028 20.1525Z"/>
<path d="M41.0417 8.22034L28.8889 20.1525L31.684 3H41.7708L41.0417 8.22034Z"/>
<path d="M30.3472 20.1525L42.5 8.22034L38.6111 30.3446L26.9444 42.5254L29.0104 28.8531H22.3264L29.6181 21.1469H30.3472V20.1525Z"/>
<path d="M40.0694 30.3446L28.4028 42.5254L27.9167 47H37.8819L40.0694 30.3446Z"/>
<path d="M18.6806 47H8.47222L8.95833 42.5254L20.8681 30.5932L18.6806 47Z"/>
</svg>
)
const ClashMetaIcon = () => (
<svg className="w-6 h-6" viewBox="0 0 50 50" fill="currentColor">
<path fillRule="evenodd" clipRule="evenodd" d="M4.99239 5.21742C4.0328 5.32232 3.19446 5.43999 3.12928 5.47886C2.94374 5.58955 2.96432 33.4961 3.14997 33.6449C3.2266 33.7062 4.44146 34.002 5.84976 34.3022C7.94234 34.7483 8.60505 34.8481 9.47521 34.8481C10.3607 34.8481 10.5706 34.8154 10.7219 34.6541C10.8859 34.479 10.9066 33.7222 10.9338 26.9143L10.9638 19.3685L11.2759 19.1094C11.6656 18.7859 12.1188 18.7789 12.5285 19.0899C12.702 19.2216 14.319 20.624 16.1219 22.2061C17.9247 23.7883 19.5136 25.1104 19.6527 25.144C19.7919 25.1777 20.3714 25.105 20.9406 24.9825C22.6144 24.6221 23.3346 24.5424 24.9233 24.5421C26.4082 24.5417 27.8618 24.71 29.2219 25.0398C29.6074 25.1333 30.0523 25.1784 30.2107 25.1399C30.369 25.1016 31.1086 24.5336 31.8543 23.8777C33.3462 22.5653 33.6461 22.3017 35.4359 20.7293C36.1082 20.1388 36.6831 19.6313 36.7137 19.6017C37.5681 18.7742 38.0857 18.6551 38.6132 19.1642L38.9383 19.478V34.5138L39.1856 34.6809C39.6343 34.9843 41.2534 34.9022 43.195 34.4775C44.1268 34.2737 45.2896 34.0291 45.779 33.9339C46.2927 33.8341 46.7276 33.687 46.8079 33.5861C47.0172 33.3228 47.0109 5.87708 46.8014 5.6005C46.6822 5.4431 46.2851 5.37063 44.605 5.1996C43.477 5.08482 42.2972 5.00505 41.983 5.02223L41.4121 5.05368L35.4898 10.261C27.3144 17.4495 27.7989 17.0418 27.5372 16.9533C27.4148 16.912 26.1045 16.8746 24.6253 16.8702C22.0674 16.8626 21.9233 16.8513 21.6777 16.6396C21.0693 16.115 17.2912 12.8028 14.5726 10.4108C12.9548 8.98729 10.9055 7.18761 10.0186 6.41134L8.40584 5L7.5715 5.01331C7.11256 5.02072 5.95198 5.11252 4.99239 5.21742Z"/>
<path d="M25.572 37.9556C25.3176 38.3822 24.6815 38.3822 24.427 37.9556L23.4728 36.3558C23.2184 35.9292 23.5364 35.396 24.0453 35.396H25.9537C26.4626 35.396 26.7807 35.9292 26.5262 36.3558L25.572 37.9556Z"/>
<path d="M3 37.3157C3 36.9034 3.3453 36.5691 3.77126 36.5691H14.3485C14.7745 36.5691 15.1198 36.9034 15.1198 37.3157C15.1198 37.728 14.7745 38.0623 14.3485 38.0623H3.77126C3.3453 38.0623 3 37.728 3 37.3157Z"/>
<path d="M3.58851 44.5029C3.44604 44.1144 3.65596 43.6876 4.05738 43.5497L14.0254 40.1251C14.4269 39.9872 14.8678 40.1904 15.0102 40.5789C15.1527 40.9675 14.9428 41.3943 14.5414 41.5322L4.57331 44.9568C4.17189 45.0947 3.73098 44.8915 3.58851 44.5029Z"/>
<path d="M47 37.3157C47 36.9034 46.6547 36.5691 46.2287 36.5691H35.6515C35.2255 36.5691 34.8802 36.9034 34.8802 37.3157C34.8802 37.728 35.2255 38.0623 35.6515 38.0623H46.2287C46.6547 38.0623 47 37.728 47 37.3157Z"/>
<path d="M46.4115 44.5029C46.554 44.1144 46.344 43.6876 45.9426 43.5497L35.9746 40.1251C35.5731 39.9872 35.1322 40.1904 34.9898 40.5789C34.8473 40.9675 35.0572 41.3943 35.4586 41.5322L45.4267 44.9568C45.8281 45.0947 46.269 44.8915 46.4115 44.5029Z"/>
</svg>
)
const ClashVergeIcon = () => (
<svg className="w-6 h-6" viewBox="0 0 50 50" fill="none">
<path d="M30.9988 13.4144C27.2973 12.0435 23.5958 11.9064 19.0716 13.4144C14.1362 10.6726 10.846 4.36632 7.967 5.05171C5.08802 5.7371 5.91058 16.8418 5.91058 24.2448C4.26545 26.4384 3.64225 29.205 4.12911 31.7851C7.69353 48.9219 40.8711 49.8816 45.8057 31.7851C46.6656 28.6319 44.8461 25.0674 44.1599 24.2448C44.1599 16.7047 44.9825 5.20727 42.2406 5.05168C38.539 4.84163 35.523 10.8096 30.9988 13.4144Z" stroke="currentColor" strokeWidth="2.5"/>
<path d="M27.9837 34.1749C27.9836 33.078 21.5403 33.0711 21.5403 34.1749C21.5403 35.8199 24.6933 37.328 24.6933 37.328C24.6933 37.328 27.9838 35.8822 27.9837 34.1749Z" fill="currentColor"/>
<path d="M17.8383 25.5362C16.6044 24.5766 13.3656 23.754 12.0803 25.8105C10.9382 27.6378 12.4017 29.8157 14.2738 30.8829C16.1459 31.95 20.0317 31.2941 20.0317 29.649C20.0317 28.004 19.0721 26.4957 17.8383 25.5362Z" fill="currentColor"/>
<path d="M31.8219 25.4405C33.0557 24.4809 36.2945 23.6583 37.5799 25.7148C38.722 27.5421 37.2584 29.72 35.3863 30.7872C33.5142 31.8543 29.6285 31.1984 29.6285 29.5533C29.6285 27.9083 30.588 26.4 31.8219 25.4405Z" fill="currentColor"/>
</svg>
)
const ShadowrocketIcon = () => (
<svg className="w-6 h-6" viewBox="0 0 50 50" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21.2394 36.832L16.5386 39.568C16.5386 39.568 13.7182 36.832 11.8379 33.184C9.95756 29.536 16.5386 23.152 16.5386 23.152M21.2394 36.832H28.7606M21.2394 36.832C21.2394 36.832 15.5985 24.064 17.4788 16.768C19.3591 9.472 25 4 25 4C25 4 30.6409 9.472 32.5212 16.768C34.4015 24.064 28.7606 36.832 28.7606 36.832M28.7606 36.832L33.4614 39.568C33.4614 39.568 36.2818 36.832 38.1621 33.184C40.0424 29.536 33.4614 23.152 33.4614 23.152M25 46L26.8803 40.528H23.1197L25 46ZM25.9402 17.68C26.4594 18.1837 26.4594 19.0003 25.9402 19.504C25.4209 20.0077 24.5791 20.0077 24.0598 19.504C23.5406 19.0003 23.5406 18.1837 24.0598 17.68C24.5791 17.1763 25.4209 17.1763 25.9402 17.68Z"/>
</svg>
)
const StreisandIcon = () => (
<svg className="w-6 h-6" viewBox="0 0 50 50" fill="currentColor">
<path d="M25 46L24.2602 47.0076C24.7027 47.3325 25.3054 47.3306 25.7459 47.0031L25 46ZM6.14773 32.1591H4.89773C4.89773 32.557 5.0872 32.9312 5.40797 33.1667L6.14773 32.1591ZM43.6136 32.1591L44.3595 33.1622C44.6767 32.9263 44.8636 32.5543 44.8636 32.1591H43.6136ZM6.14773 19.9886L5.42485 18.9689C5.09421 19.2032 4.89773 19.5834 4.89773 19.9886H6.14773ZM25 6.625L25.729 5.6096L25.0046 5.08952L24.2771 5.60522L25 6.625ZM43.6136 19.9886H44.8636C44.8636 19.586 44.6697 19.208 44.3426 18.9732L43.6136 19.9886ZM3.25 38.5568C2.69772 38.971 2.58579 39.7545 3 40.3068C3.41421 40.8591 4.19772 40.971 4.75 40.5568L4 39.5568L3.25 38.5568ZM13.3409 34.1136C13.8932 33.6994 14.0051 32.9159 13.5909 32.3636C13.1767 31.8114 12.3932 31.6994 11.8409 32.1136L12.5909 33.1136L13.3409 34.1136ZM8.97727 42.8523C8.42499 43.2665 8.31306 44.05 8.72727 44.6023C9.14149 45.1546 9.92499 45.2665 10.4773 44.8523L9.72727 43.8523L8.97727 42.8523ZM19.0682 38.4091C19.6205 37.9949 19.7324 37.2114 19.3182 36.6591C18.904 36.1068 18.1205 35.9949 17.5682 36.4091L18.3182 37.4091L19.0682 38.4091ZM45.25 40.5568C45.8023 40.971 46.5858 40.8591 47 40.3068C47.4142 39.7545 47.3023 38.971 46.75 38.5568L46 39.5568L45.25 40.5568ZM38.1591 32.1136C37.6068 31.6994 36.8233 31.8114 36.4091 32.3636C35.9949 32.9159 36.1068 33.6994 36.6591 34.1136L37.4091 33.1136L38.1591 32.1136ZM39.5227 44.8523C40.075 45.2665 40.8585 45.1546 41.2727 44.6023C41.6869 44.05 41.575 43.2665 41.0227 42.8523L40.2727 43.8523L39.5227 44.8523ZM32.4318 36.4091C31.8795 35.9949 31.096 36.1068 30.6818 36.6591C30.2676 37.2114 30.3795 37.9949 30.9318 38.4091L31.6818 37.4091L32.4318 36.4091ZM46.2727 9.29545C46.825 8.88124 46.9369 8.09774 46.5227 7.54545C46.1085 6.99317 45.325 6.88124 44.7727 7.29545L45.5227 8.29545L46.2727 9.29545ZM36.1818 13.7386C35.6295 14.1528 35.5176 14.9364 35.9318 15.4886C36.346 16.0409 37.1295 16.1528 37.6818 15.7386L36.9318 14.7386L36.1818 13.7386ZM40.5455 5C41.0977 4.58579 41.2097 3.80228 40.7955 3.25C40.3812 2.69772 39.5977 2.58579 39.0455 3L39.7955 4L40.5455 5ZM30.4545 9.44318C29.9023 9.8574 29.7903 10.6409 30.2045 11.1932C30.6188 11.7455 31.4023 11.8574 31.9545 11.4432L31.2045 10.4432L30.4545 9.44318ZM5.70455 7.29545C5.15226 6.88124 4.36876 6.99317 3.95455 7.54545C3.54033 8.09774 3.65226 8.88124 4.20455 9.29545L4.95455 8.29545L5.70455 7.29545ZM12.7955 15.7386C13.3477 16.1528 14.1312 16.0409 14.5455 15.4886C14.9597 14.9364 14.8477 14.1528 14.2955 13.7386L13.5455 14.7386L12.7955 15.7386ZM11.4318 3C10.8795 2.58579 10.096 2.69772 9.68182 3.25C9.2676 3.80228 9.37953 4.58579 9.93182 5L10.6818 4L11.4318 3ZM18.5227 11.4432C19.075 11.8574 19.8585 11.7455 20.2727 11.1932C20.6869 10.6409 20.575 9.8574 20.0227 9.44318L19.2727 10.4432L18.5227 11.4432ZM25 46L25.7398 44.9924L6.88748 31.1515L6.14773 32.1591L5.40797 33.1667L24.2602 47.0076L25 46ZM43.6136 32.1591L42.8678 31.156L24.2541 44.9969L25 46L25.7459 47.0031L44.3595 33.1622L43.6136 32.1591ZM25 33.8295L25.7398 32.8219L6.88748 18.981L6.14773 19.9886L5.40797 20.9962L24.2602 34.8371L25 33.8295ZM6.14773 19.9886L6.87061 21.0084L25.7229 7.64478L25 6.625L24.2771 5.60522L5.42485 18.9689L6.14773 19.9886ZM25 6.625L24.271 7.6404L42.8846 21.004L43.6136 19.9886L44.3426 18.9732L25.729 5.6096L25 6.625ZM43.6136 19.9886L42.8678 18.9856L24.2541 32.8265L25 33.8295L25.7459 34.8326L44.3595 20.9917L43.6136 19.9886ZM43.6136 32.1591H44.8636V19.9886H43.6136H42.3636V32.1591H43.6136ZM25 33.8295H23.75V46H25H26.25V33.8295H25ZM6.14773 32.1591H7.39773V19.9886H6.14773H4.89773V32.1591H6.14773ZM4 39.5568L4.75 40.5568L13.3409 34.1136L12.5909 33.1136L11.8409 32.1136L3.25 38.5568L4 39.5568ZM9.72727 43.8523L10.4773 44.8523L19.0682 38.4091L18.3182 37.4091L17.5682 36.4091L8.97727 42.8523L9.72727 43.8523ZM46 39.5568L46.75 38.5568L38.1591 32.1136L37.4091 33.1136L36.6591 34.1136L45.25 40.5568L46 39.5568ZM40.2727 43.8523L41.0227 42.8523L32.4318 36.4091L31.6818 37.4091L30.9318 38.4091L39.5227 44.8523L40.2727 43.8523ZM45.5227 8.29545L44.7727 7.29545L36.1818 13.7386L36.9318 14.7386L37.6818 15.7386L46.2727 9.29545L45.5227 8.29545ZM39.7955 4L39.0455 3L30.4545 9.44318L31.2045 10.4432L31.9545 11.4432L40.5455 5L39.7955 4ZM4.95455 8.29545L4.20455 9.29545L12.7955 15.7386L13.5455 14.7386L14.2955 13.7386L5.70455 7.29545L4.95455 8.29545ZM10.6818 4L9.93182 5L18.5227 11.4432L19.2727 10.4432L20.0227 9.44318L11.4318 3L10.6818 4Z"/>
</svg>
)
// App icon mapping by name (case-insensitive)
const getAppIcon = (appName: string, isFeatured: boolean): React.ReactNode => {
const name = appName.toLowerCase()
if (name.includes('happ')) {
return <HappIcon />
}
if (name.includes('shadowrocket') || name.includes('rocket')) {
return <ShadowrocketIcon />
}
if (name.includes('streisand')) {
return <StreisandIcon />
}
if (name.includes('verge')) {
return <ClashVergeIcon />
}
if (name.includes('clash') || name.includes('meta')) {
return <ClashMetaIcon />
}
// Default icons
return isFeatured ? '⭐' : '📦'
}
// Platform order for display
const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']
// Dangerous schemes that should never be allowed
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']
/**
* Validate URL to prevent XSS via javascript: and other dangerous schemes
* Only allows http, https, and known app store URLs
*/
function isValidExternalUrl(url: string | undefined): boolean {
if (!url) return false
const lowerUrl = url.toLowerCase().trim()
// Block dangerous schemes
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']
if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) {
return false
}
// Allow only http/https URLs
return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://')
}
/**
* Validate deep link URL - blocks dangerous schemes only
* Allows any custom app scheme (clash://, hiddify://, etc.)
*/
function isValidDeepLink(url: string | undefined): boolean {
if (!url) return false
const lowerUrl = url.toLowerCase().trim()
// Block dangerous schemes
if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) {
return false
}
// Allow any URL that has a scheme (contains ://)
return lowerUrl.includes('://')
}
// Detect user's platform from user agent
function detectPlatform(): string | null {
if (typeof window === 'undefined' || !navigator?.userAgent) {
return null
}
const ua = navigator.userAgent.toLowerCase()
// Check for mobile devices first
if (/iphone|ipad|ipod/.test(ua)) {
return 'ios'
}
if (/iphone|ipad|ipod/.test(ua)) return 'ios'
if (/android/.test(ua)) {
// Check if it's Android TV
if (/tv|television|smart-tv|smarttv/.test(ua)) {
return 'androidTV'
}
if (/tv|television|smart-tv|smarttv/.test(ua)) return 'androidTV'
return 'android'
}
// Desktop platforms
if (/macintosh|mac os x/.test(ua)) {
return 'macos'
}
if (/windows/.test(ua)) {
return 'windows'
}
if (/linux/.test(ua)) {
return 'linux'
}
if (/macintosh|mac os x/.test(ua)) return 'macos'
if (/windows/.test(ua)) return 'windows'
if (/linux/.test(ua)) return 'linux'
return null
}
export default function ConnectionModal({ onClose }: ConnectionModalProps) {
const { t, i18n } = useTranslation()
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null)
const [selectedApp, setSelectedApp] = useState<AppInfo | null>(null)
const [copied, setCopied] = useState(false)
const [detectedPlatform, setDetectedPlatform] = useState<string | null>(null)
const [showAppSelector, setShowAppSelector] = useState(false)
const modalContentRef = useRef<HTMLDivElement>(null)
const { data: appConfig, isLoading, error } = useQuery<AppConfig>({
queryKey: ['appConfig'],
queryFn: () => subscriptionApi.getAppConfig(),
})
// Auto-detect platform on mount
useEffect(() => {
const detected = detectPlatform()
setDetectedPlatform(detected)
setDetectedPlatform(detectPlatform())
}, [])
// Auto-select platform and app when data is loaded
useEffect(() => {
if (!appConfig?.platforms || selectedApp) return
const platform = detectedPlatform || platformOrder.find(p => appConfig.platforms[p]?.length > 0)
if (!platform || !appConfig.platforms[platform]?.length) return
const apps = appConfig.platforms[platform]
// Prefer featured app, otherwise first app
const app = apps.find(a => a.isFeatured) || apps[0]
if (app) {
setSelectedApp(app)
}
}, [appConfig, detectedPlatform, selectedApp])
// Scroll modal content to top when switching views
useEffect(() => {
modalContentRef.current?.scrollTo({ top: 0, behavior: 'instant' })
}, [showAppSelector])
// Scroll lock when modal is open
useEffect(() => {
const scrollY = window.scrollY
// Prevent all touch/wheel scroll on backdrop
const preventScroll = (e: TouchEvent) => {
const target = e.target as HTMLElement
if (target.closest('[data-modal-content]')) return
e.preventDefault()
}
const preventWheel = (e: WheelEvent) => {
const target = e.target as HTMLElement
if (target.closest('[data-modal-content]')) return
e.preventDefault()
}
document.addEventListener('touchmove', preventScroll, { passive: false })
document.addEventListener('wheel', preventWheel, { passive: false })
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('touchmove', preventScroll)
document.removeEventListener('wheel', preventWheel)
document.body.style.overflow = ''
window.scrollTo(0, scrollY)
}
}, [])
// Helper to get localized text
const getLocalizedText = (text: LocalizedText | undefined): string => {
if (!text) return ''
const lang = i18n.language || 'en'
return text[lang] || text['en'] || text['ru'] || Object.values(text)[0] || ''
}
// Get platform name
const getPlatformName = (platformKey: string): string => {
if (!appConfig?.platformNames?.[platformKey]) {
return platformKey
@@ -166,13 +279,11 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
return getLocalizedText(appConfig.platformNames[platformKey])
}
// Get available platforms sorted (detected platform first)
const availablePlatforms = useMemo(() => {
if (!appConfig?.platforms) return []
const available = platformOrder.filter(
(key) => appConfig.platforms[key] && appConfig.platforms[key].length > 0
)
// Move detected platform to the front
if (detectedPlatform && available.includes(detectedPlatform)) {
const filtered = available.filter(p => p !== detectedPlatform)
return [detectedPlatform, ...filtered]
@@ -180,13 +291,19 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
return available
}, [appConfig, detectedPlatform])
// Get apps for selected platform
const platformApps = useMemo(() => {
if (!selectedPlatform || !appConfig?.platforms?.[selectedPlatform]) return []
return appConfig.platforms[selectedPlatform]
}, [selectedPlatform, appConfig])
// Get all apps for selector (must be before any conditional returns)
const allAppsForSelector = useMemo(() => {
if (!appConfig?.platforms) return []
const result: { platform: string; apps: AppInfo[] }[] = []
for (const platform of availablePlatforms) {
const apps = appConfig.platforms[platform]
if (apps?.length) {
result.push({ platform, apps })
}
}
return result
}, [appConfig, availablePlatforms])
// Copy subscription link
const copySubscriptionLink = async () => {
if (!appConfig?.subscriptionUrl) return
try {
@@ -194,7 +311,6 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// Fallback for older browsers
const textarea = document.createElement('textarea')
textarea.value = appConfig.subscriptionUrl
document.body.appendChild(textarea)
@@ -206,24 +322,16 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
}
}
// Handle deep link click - use miniapp redirect page like in miniapp index.html
const handleConnect = (app: AppInfo) => {
// Validate deep link to prevent XSS
if (!app.deepLink || !isValidDeepLink(app.deepLink)) {
console.warn('Invalid or missing deep link:', app.deepLink)
return
}
const lang = i18n.language?.startsWith('ru') ? 'ru' : 'en'
const redirectUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(app.deepLink)}&lang=${lang}`
// Check if it's a custom URL scheme (not http/https)
const isCustomScheme = !/^https?:\/\//i.test(app.deepLink)
const tg = (window as any).Telegram?.WebApp
const tg = (window as unknown as { Telegram?: { WebApp?: { openLink?: (url: string, options?: object) => void } } }).Telegram?.WebApp
if (isCustomScheme && tg?.openLink) {
// For custom URL schemes - open redirect page in external browser via Telegram
// try_browser: true - показывает диалог для перехода во внешний браузер (важно для мобильных)
try {
tg.openLink(redirectUrl, { try_instant_view: false, try_browser: true })
return
@@ -231,260 +339,207 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
console.warn('tg.openLink failed:', e)
}
}
// Fallback - direct navigation
window.location.href = redirectUrl
}
// Modal wrapper classes - bottom sheet on mobile, centered on desktop
const modalOverlayClass = "fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-end sm:items-center sm:justify-center"
const modalCardClass = "w-full sm:max-w-lg sm:mx-4 bg-dark-850 sm:rounded-2xl rounded-t-2xl rounded-b-none border-t border-x sm:border border-dark-700/50 shadow-2xl overflow-hidden"
const modalContentClass = "p-4 sm:p-6 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-6"
// Allow the sheet to almost fill the viewport height on phones
const modalScrollableClass = "h-[calc(100vh-1rem)] sm:h-auto sm:max-h-[85vh]"
// Modal wrapper - top aligned with auto-focus
const ModalWrapper = ({ children }: { children: React.ReactNode }) => (
<div
className="fixed inset-0 bg-black/70 z-[60] flex items-start justify-center p-4 pt-8 overflow-hidden"
style={{
paddingTop: `max(2rem, calc(1rem + env(safe-area-inset-top, 0px)))`,
paddingBottom: `max(1rem, env(safe-area-inset-bottom, 0px))`,
}}
onClick={onClose}
>
<div
ref={modalContentRef}
data-modal-content
tabIndex={-1}
className="w-full max-w-sm bg-dark-900 rounded-2xl border border-dark-700/50 overflow-y-auto overscroll-contain animate-scale-in shadow-2xl flex flex-col outline-none"
style={{
maxHeight: `calc(100vh - 4rem - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))`,
WebkitOverflowScrolling: 'touch',
}}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
)
// Loading state
if (isLoading) {
return (
<div className={modalOverlayClass} onClick={onClose}>
<div className={modalCardClass} onClick={(e) => e.stopPropagation()}>
<div className={modalContentClass}>
<div className="flex justify-center py-8">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
</div>
<ModalWrapper>
<div className="flex justify-center py-16">
<div className="w-10 h-10 border-3 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
</div>
</ModalWrapper>
)
}
// Error state
if (error || !appConfig) {
return (
<div className={modalOverlayClass} onClick={onClose}>
<div className={modalCardClass} onClick={(e) => e.stopPropagation()}>
<div className={modalContentClass}>
<div className="text-center py-8">
<p className="text-error-400">{t('common.error')}</p>
<button onClick={onClose} className="btn-secondary mt-4">
{t('common.close')}
</button>
</div>
<ModalWrapper>
<div className="text-center p-6">
<div className="w-14 h-14 mx-auto mb-3 rounded-2xl bg-error-500/10 flex items-center justify-center">
<span className="text-2xl">😕</span>
</div>
<p className="text-dark-300 mb-4 text-sm">{t('common.error')}</p>
<button onClick={onClose} className="btn-primary text-sm px-6">
{t('common.close')}
</button>
</div>
</div>
</ModalWrapper>
)
}
// No subscription
if (!appConfig.hasSubscription) {
return (
<div className={modalOverlayClass} onClick={onClose}>
<div className={modalCardClass} onClick={(e) => e.stopPropagation()}>
<div className={modalContentClass}>
<div className="flex justify-between items-center mb-6">
<h2 className="text-lg font-semibold text-dark-100">
{t('subscription.connection.title')}
</h2>
<button onClick={onClose} className="btn-icon">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="text-center py-8">
<p className="text-dark-400">{t('subscription.connection.noSubscription')}</p>
</div>
<ModalWrapper>
<div className="text-center p-6">
<div className="w-14 h-14 mx-auto mb-3 rounded-2xl bg-warning-500/10 flex items-center justify-center">
<span className="text-2xl">📱</span>
</div>
<h3 className="font-semibold text-dark-100 mb-1">{t('subscription.connection.title')}</h3>
<p className="text-dark-400 text-sm mb-4">{t('subscription.connection.noSubscription')}</p>
<button onClick={onClose} className="btn-primary text-sm px-6">
{t('common.close')}
</button>
</div>
</div>
</ModalWrapper>
)
}
// Step 1: Select platform
if (!selectedPlatform) {
// App selector view
if (showAppSelector || !selectedApp) {
return (
<div className={modalOverlayClass} onClick={onClose}>
<div className={`${modalCardClass} ${modalScrollableClass} flex flex-col animate-slide-up`} onClick={(e) => e.stopPropagation()}>
{/* Header - fixed */}
<div className="flex justify-between items-center p-4 sm:p-6 pb-0 flex-shrink-0">
<h2 className="text-lg font-semibold text-dark-100">
{t('subscription.connection.title')}
</h2>
<button onClick={onClose} className="btn-icon">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<ModalWrapper>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-800">
<div className="flex items-center gap-2">
{selectedApp && (
<button onClick={() => setShowAppSelector(false)} className="p-2 -ml-2 rounded-xl hover:bg-dark-800 text-dark-400">
<BackIcon />
</button>
)}
<div>
<h2 className="font-semibold text-dark-100 text-sm">{t('subscription.connection.selectApp')}</h2>
<p className="text-xs text-dark-500">{t('subscription.connection.selectDevice')}</p>
</div>
</div>
<button onClick={onClose} className="p-2 rounded-xl hover:bg-dark-800 text-dark-400">
<CloseIcon />
</button>
</div>
{/* Scrollable content */}
<div className="flex-1 min-h-0 overflow-y-auto p-4 sm:p-6 pt-3 sm:pt-4">
<p className="text-dark-400 text-sm sm:text-base mb-3 sm:mb-4">{t('subscription.connection.selectDevice')}</p>
<div className="grid grid-cols-2 gap-2 sm:gap-3">
{availablePlatforms.map((platform) => {
const IconComponent = platformIconComponents[platform]
return (
<button
key={platform}
onClick={() => setSelectedPlatform(platform)}
className={`p-3 sm:p-4 rounded-xl border transition-all text-left group ${
platform === detectedPlatform
? 'bg-accent-500/10 border-accent-500/50 hover:border-accent-500'
: 'bg-dark-800/50 border-dark-700 hover:border-accent-500/50 hover:bg-dark-700/50'
}`}
>
<div className="flex items-start justify-between">
<div className={`${
platform === detectedPlatform ? 'text-accent-400' : 'text-dark-400 group-hover:text-dark-200'
} transition-colors`}>
{IconComponent && <IconComponent />}
{/* Apps by platform */}
<div className="p-4 space-y-4 max-h-[60vh] overflow-y-auto">
{allAppsForSelector.map(({ platform, apps }) => {
const IconComponent = platformIconComponents[platform]
const isCurrentPlatform = platform === detectedPlatform
return (
<div key={platform}>
<div className="flex items-center gap-2 mb-2">
<div className={`${isCurrentPlatform ? 'text-accent-400' : 'text-dark-500'}`}>
{IconComponent && <IconComponent />}
</div>
<span className={`text-xs font-medium ${isCurrentPlatform ? 'text-accent-400' : 'text-dark-400'}`}>
{getPlatformName(platform)}
{isCurrentPlatform && <span className="ml-1 text-[10px]">({t('subscription.connection.yourDevice')})</span>}
</span>
</div>
<div className="space-y-1.5">
{apps.map((app) => (
<button
key={app.id}
onClick={() => {
setSelectedApp(app)
setShowAppSelector(false)
}}
className={`w-full p-2.5 rounded-xl border transition-all flex items-center gap-3 ${
selectedApp?.id === app.id
? 'bg-accent-500/10 border-accent-500/50'
: 'bg-dark-800/50 border-dark-700/50 hover:border-dark-600'
}`}
>
<div className="w-8 h-8 rounded-lg bg-dark-700 flex items-center justify-center text-sm">
{getAppIcon(app.name, app.isFeatured)}
</div>
{platform === detectedPlatform && (
<span className="px-2 py-0.5 rounded-full text-xs bg-accent-500/20 text-accent-400">
{t('subscription.connection.yourDevice')}
</span>
)}
</div>
<p className="text-dark-200 font-medium mt-2">{getPlatformName(platform)}</p>
</button>
)
})}
</div>
</div>
{/* Footer - fixed with safe area */}
<div className="flex-shrink-0 p-4 sm:p-6 pt-3 sm:pt-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-6 border-t border-dark-700/50">
<button
onClick={copySubscriptionLink}
className="w-full p-2.5 sm:p-3 rounded-xl bg-dark-800/50 border border-dark-700 hover:border-accent-500/50 transition-all flex items-center justify-center gap-2 text-sm sm:text-base"
>
{copied ? (
<>
<svg className="w-5 h-5 text-success-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span className="text-success-400">{t('subscription.connection.copied')}</span>
</>
) : (
<>
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span className="text-dark-300">{t('subscription.connection.copyLink')}</span>
</>
)}
</button>
</div>
</div>
</div>
)
}
// Step 2: Select app (if not selected yet)
if (!selectedApp) {
return (
<div className={modalOverlayClass} onClick={onClose}>
<div className={`${modalCardClass} ${modalScrollableClass} flex flex-col animate-slide-up`} onClick={(e) => e.stopPropagation()}>
{/* Header - fixed */}
<div className="flex justify-between items-center p-4 sm:p-6 pb-0 flex-shrink-0">
<div className="flex items-center gap-2">
<button
onClick={() => setSelectedPlatform(null)}
className="btn-icon"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<h2 className="text-lg font-semibold text-dark-100">
{getPlatformName(selectedPlatform)}
</h2>
</div>
<button onClick={onClose} className="btn-icon">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Scrollable content */}
<div className="flex-1 min-h-0 overflow-y-auto p-4 sm:p-6 pt-3 sm:pt-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-6">
<p className="text-dark-400 text-sm sm:text-base mb-3 sm:mb-4">{t('subscription.connection.selectApp')}</p>
{platformApps.length === 0 ? (
<div className="text-center py-6 sm:py-8">
<p className="text-dark-500">{t('subscription.connection.noApps')}</p>
</div>
) : (
<div className="space-y-2 sm:space-y-3">
{platformApps.map((app) => (
<button
key={app.id}
onClick={() => setSelectedApp(app)}
className="w-full p-3 sm:p-4 rounded-xl bg-dark-800/50 border border-dark-700 hover:border-accent-500/50 hover:bg-dark-700/50 transition-all text-left flex items-center justify-between"
>
<div>
<div className="flex items-center gap-2">
<span className="text-dark-100 font-medium">{app.name}</span>
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="font-medium text-dark-100 text-sm truncate">{app.name}</span>
{app.isFeatured && (
<span className="px-2 py-0.5 rounded-full text-xs bg-accent-500/20 text-accent-400">
<span className="px-1.5 py-0.5 rounded text-[9px] font-medium bg-accent-500/20 text-accent-400 shrink-0">
{t('subscription.connection.featured')}
</span>
)}
</div>
</div>
<svg className="w-5 h-5 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
))}
<ChevronIcon />
</button>
))}
</div>
</div>
)}
</div>
)
})}
</div>
</div>
{/* Copy link */}
<div className="p-4 border-t border-dark-800">
<button
onClick={copySubscriptionLink}
className={`w-full p-2.5 rounded-xl border-2 border-dashed transition-all flex items-center justify-center gap-2 text-sm ${
copied
? 'border-success-500/50 bg-success-500/10 text-success-400'
: 'border-dark-700 hover:border-accent-500/50 text-dark-400'
}`}
>
{copied ? <CheckIcon /> : <CopyIcon />}
{copied ? t('subscription.connection.copied') : t('subscription.connection.copyLink')}
</button>
</div>
</ModalWrapper>
)
}
// Step 3: Show app instructions and connect button
// App instructions (main view)
return (
<div className={modalOverlayClass} onClick={onClose}>
<div className={`${modalCardClass} ${modalScrollableClass} flex flex-col animate-slide-up`} onClick={(e) => e.stopPropagation()}>
{/* Header - fixed */}
<div className="flex justify-between items-center p-4 sm:p-6 pb-0 flex-shrink-0">
<div className="flex items-center gap-2">
<button
onClick={() => setSelectedApp(null)}
className="btn-icon"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<h2 className="text-lg font-semibold text-dark-100">
{selectedApp.name}
</h2>
<ModalWrapper>
{/* Header with app info and change button */}
<div className="flex items-center justify-between p-4 border-b border-dark-800">
<button
onClick={() => setShowAppSelector(true)}
className="flex items-center gap-3 p-2 -m-2 rounded-xl hover:bg-dark-800/50 transition-colors"
>
<div className="w-10 h-10 rounded-xl bg-dark-700 flex items-center justify-center">
{getAppIcon(selectedApp.name, selectedApp.isFeatured)}
</div>
<button onClick={onClose} className="btn-icon">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="text-left">
<h2 className="font-semibold text-dark-100 text-sm">{selectedApp.name}</h2>
<p className="text-xs text-accent-400">{t('subscription.connection.changeApp') || 'Сменить'} </p>
</div>
</button>
<button onClick={onClose} className="p-2 rounded-xl hover:bg-dark-800 text-dark-400">
<CloseIcon />
</button>
</div>
{/* Scrollable content */}
<div className="flex-1 min-h-0 overflow-y-auto p-4 sm:p-6 pt-3 sm:pt-4">
<div className="space-y-4 sm:space-y-6">
{/* Step 1: Install */}
{selectedApp.installationStep && (
<div className="space-y-2 sm:space-y-3">
<h3 className="text-sm font-medium text-accent-400">
{t('subscription.connection.installApp')}
</h3>
<p className="text-sm text-dark-400">
{/* Instructions */}
<div className="p-4 space-y-3 max-h-[60vh] overflow-y-auto">
{/* Step 1: Install */}
{selectedApp.installationStep && (
<div className="p-3 rounded-xl bg-dark-800/50 border border-dark-700/50">
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-accent-500/20 flex items-center justify-center text-xs font-bold text-accent-400 shrink-0 mt-0.5">1</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-dark-100 text-sm mb-1">{t('subscription.connection.installApp')}</h3>
<p className="text-xs text-dark-400 mb-2">
{getLocalizedText(selectedApp.installationStep.description)}
</p>
{selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && (
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-1.5">
{selectedApp.installationStep.buttons
.filter((btn) => isValidExternalUrl(btn.buttonLink))
.map((btn, idx) => (
@@ -493,115 +548,78 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
href={btn.buttonLink}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-dark-700 text-dark-200 text-sm hover:bg-dark-600 transition-all"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-dark-700 text-dark-200 text-xs hover:bg-dark-600 transition-all"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
<LinkIcon />
{getLocalizedText(btn.buttonText)}
</a>
))}
</div>
)}
</div>
)}
</div>
</div>
)}
{/* Additional before add subscription */}
{selectedApp.additionalBeforeAddSubscriptionStep && (
<div className="space-y-2 p-3 rounded-xl bg-dark-800/50 border border-dark-700">
{selectedApp.additionalBeforeAddSubscriptionStep.title && (
<h4 className="text-sm font-medium text-dark-200">
{getLocalizedText(selectedApp.additionalBeforeAddSubscriptionStep.title)}
</h4>
)}
<p className="text-xs text-dark-400">
{getLocalizedText(selectedApp.additionalBeforeAddSubscriptionStep.description)}
</p>
</div>
)}
{/* Step 2: Add subscription */}
{selectedApp.addSubscriptionStep && (
<div className="space-y-2 sm:space-y-3">
<h3 className="text-sm font-medium text-accent-400">
{t('subscription.connection.addSubscription')}
</h3>
<p className="text-sm text-dark-400">
{/* Step 2: Add subscription */}
{selectedApp.addSubscriptionStep && (
<div className="p-3 rounded-xl bg-dark-800/50 border border-dark-700/50">
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-accent-500/20 flex items-center justify-center text-xs font-bold text-accent-400 shrink-0 mt-0.5">2</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-dark-100 text-sm mb-1">{t('subscription.connection.addSubscription')}</h3>
<p className="text-xs text-dark-400 mb-2">
{getLocalizedText(selectedApp.addSubscriptionStep.description)}
</p>
{/* Connect button */}
{selectedApp.deepLink && (
<button
onClick={() => handleConnect(selectedApp)}
className="btn-primary w-full py-2.5 sm:py-3 flex items-center justify-center gap-2 text-sm sm:text-base"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
{t('subscription.connection.addToApp', { appName: selectedApp.name })}
</button>
)}
{/* Copy link fallback */}
<button
onClick={copySubscriptionLink}
className="w-full p-2.5 sm:p-3 rounded-xl bg-dark-800/50 border border-dark-700 hover:border-accent-500/50 transition-all flex items-center justify-center gap-2 text-sm sm:text-base"
>
{copied ? (
<>
<svg className="w-5 h-5 text-success-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span className="text-success-400">{t('subscription.connection.copied')}</span>
</>
) : (
<>
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span className="text-dark-300">{t('subscription.connection.copyLink')}</span>
</>
<div className="space-y-1.5">
{selectedApp.deepLink && (
<button
onClick={() => handleConnect(selectedApp)}
className="btn-primary w-full py-2 text-sm flex items-center justify-center gap-2"
>
<LinkIcon />
{t('subscription.connection.addToApp', { appName: selectedApp.name })}
</button>
)}
</button>
<button
onClick={copySubscriptionLink}
className={`w-full p-2 rounded-lg border transition-all flex items-center justify-center gap-2 text-xs ${
copied
? 'border-success-500/50 bg-success-500/10 text-success-400'
: 'border-dark-700 hover:border-dark-600 bg-dark-800/50 text-dark-300'
}`}
>
{copied ? <CheckIcon /> : <CopyIcon />}
{copied ? t('subscription.connection.copied') : t('subscription.connection.copyLink')}
</button>
</div>
</div>
)}
</div>
</div>
)}
{/* Additional after add subscription */}
{selectedApp.additionalAfterAddSubscriptionStep && (
<div className="space-y-2 p-3 rounded-xl bg-dark-800/50 border border-dark-700">
{selectedApp.additionalAfterAddSubscriptionStep.title && (
<h4 className="text-sm font-medium text-dark-200">
{getLocalizedText(selectedApp.additionalAfterAddSubscriptionStep.title)}
</h4>
)}
{/* Step 3: Connect */}
{selectedApp.connectAndUseStep && (
<div className="p-3 rounded-xl bg-dark-800/50 border border-dark-700/50">
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-success-500/20 flex items-center justify-center text-xs font-bold text-success-400 shrink-0 mt-0.5">3</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-dark-100 text-sm mb-1">{t('subscription.connection.connectVpn')}</h3>
<p className="text-xs text-dark-400">
{getLocalizedText(selectedApp.additionalAfterAddSubscriptionStep.description)}
</p>
</div>
)}
{/* Step 3: Connect */}
{selectedApp.connectAndUseStep && (
<div className="space-y-2 sm:space-y-3">
<h3 className="text-sm font-medium text-accent-400">
{t('subscription.connection.connectVpn')}
</h3>
<p className="text-sm text-dark-400">
{getLocalizedText(selectedApp.connectAndUseStep.description)}
</p>
</div>
)}
</div>
</div>
</div>
{/* Footer - fixed with safe area */}
<div className="flex-shrink-0 p-4 sm:p-6 pt-3 sm:pt-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-6 border-t border-dark-700/50">
<button onClick={onClose} className="btn-secondary w-full text-sm sm:text-base">
{t('common.close')}
</button>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="p-4 border-t border-dark-800">
<button onClick={onClose} className="btn-secondary w-full text-sm">
{t('common.close')}
</button>
</div>
</ModalWrapper>
)
}

View File

@@ -1,4 +1,4 @@
import { useState, useRef } from 'react'
import { useState, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useMutation } from '@tanstack/react-query'
import { balanceApi } from '../api/balance'
@@ -55,6 +55,35 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
)
const popupRef = useRef<Window | null>(null)
// Scroll lock when modal is open
useEffect(() => {
const scrollY = window.scrollY
// Prevent all touch/wheel scroll on backdrop
const preventScroll = (e: TouchEvent) => {
const target = e.target as HTMLElement
if (target.closest('[data-modal-content]')) return
e.preventDefault()
}
const preventWheel = (e: WheelEvent) => {
const target = e.target as HTMLElement
if (target.closest('[data-modal-content]')) return
e.preventDefault()
}
document.addEventListener('touchmove', preventScroll, { passive: false })
document.addEventListener('wheel', preventWheel, { passive: false })
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('touchmove', preventScroll)
document.removeEventListener('wheel', preventWheel)
document.body.style.overflow = ''
window.scrollTo(0, scrollY)
}
}, [])
const hasOptions = method.options && method.options.length > 0
const minRubles = method.min_amount_kopeks / 100
const maxRubles = method.max_amount_kopeks / 100
@@ -132,11 +161,28 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
: convertAmount(rub).toFixed(currencyDecimals)
const isPending = topUpMutation.isPending || starsPaymentMutation.isPending
return (
<div className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center px-4 pt-14 pb-28 sm:pt-0 sm:pb-0">
<div className="absolute inset-0" onClick={onClose} />
// Auto-focus input on mount
useEffect(() => {
const timer = setTimeout(() => {
inputRef.current?.focus()
}, 100)
return () => clearTimeout(timer)
}, [])
<div className="relative w-full max-w-sm bg-dark-900 rounded-2xl border border-dark-700/50 shadow-2xl overflow-hidden">
return (
<div
className="fixed inset-0 bg-black/70 z-[60] flex items-start justify-center p-4 pt-8 overflow-hidden"
style={{
paddingTop: `max(2rem, calc(1rem + env(safe-area-inset-top, 0px)))`,
paddingBottom: `max(1rem, env(safe-area-inset-bottom, 0px))`,
}}
onClick={onClose}
>
<div
data-modal-content
className="w-full max-w-sm bg-dark-900 rounded-2xl border border-dark-700/50 shadow-2xl overflow-hidden animate-scale-in"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-dark-800/50">
<span className="font-semibold text-dark-100">{methodName}</span>

View File

@@ -15,6 +15,7 @@ import { themeColorsApi } from '../../api/themeColors'
import { promoApi } from '../../api/promo'
import { useTheme } from '../../hooks/useTheme'
import { useTelegramWebApp } from '../../hooks/useTelegramWebApp'
import { usePullToRefresh } from '../../hooks/usePullToRefresh'
// Fallback branding from environment variables
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet'
@@ -123,18 +124,6 @@ const WheelIcon = () => (
</svg>
)
const FullscreenIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
</svg>
)
const ExitFullscreenIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />
</svg>
)
export default function Layout({ children }: LayoutProps) {
const { t } = useTranslation()
const location = useLocation()
@@ -142,7 +131,13 @@ export default function Layout({ children }: LayoutProps) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const { toggleTheme, isDark } = useTheme()
const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null)
const { isTelegramWebApp, isFullscreen, isFullscreenSupported, toggleFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp()
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp()
// Pull to refresh (disabled when mobile menu is open)
const { isPulling, pullDistance, isRefreshing, progress } = usePullToRefresh({
disabled: mobileMenuOpen,
threshold: 80,
})
// Fetch enabled themes from API - same source of truth as AdminSettings
const { data: enabledThemes } = useQuery({
@@ -167,17 +162,58 @@ export default function Layout({ children }: LayoutProps) {
}
}, [])
// Lock body scroll and scroll to top when mobile menu is open
// Lock body scroll when mobile menu is open (cross-platform)
useEffect(() => {
if (mobileMenuOpen) {
// Scroll to top so header is visible
window.scrollTo({ top: 0, behavior: 'instant' })
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
if (!mobileMenuOpen) return
const scrollY = window.scrollY
const body = document.body
const html = document.documentElement
// Save original styles
const originalStyles = {
bodyOverflow: body.style.overflow,
bodyPosition: body.style.position,
bodyTop: body.style.top,
bodyWidth: body.style.width,
bodyHeight: body.style.height,
htmlOverflow: html.style.overflow,
htmlHeight: html.style.height,
}
// Lock scroll - works on iOS, Android, and desktop
body.style.overflow = 'hidden'
body.style.position = 'fixed'
body.style.top = `-${scrollY}px`
body.style.width = '100%'
body.style.height = '100%'
html.style.overflow = 'hidden'
html.style.height = '100%'
// Prevent touchmove on body (extra protection for mobile)
const preventScroll = (e: TouchEvent) => {
const target = e.target as HTMLElement
// Allow scroll inside menu content
if (target.closest('.mobile-menu-content')) return
e.preventDefault()
}
document.addEventListener('touchmove', preventScroll, { passive: false })
return () => {
document.body.style.overflow = ''
// Restore original styles
body.style.overflow = originalStyles.bodyOverflow
body.style.position = originalStyles.bodyPosition
body.style.top = originalStyles.bodyTop
body.style.width = originalStyles.bodyWidth
body.style.height = originalStyles.bodyHeight
html.style.overflow = originalStyles.htmlOverflow
html.style.height = originalStyles.htmlHeight
// Remove touchmove listener
document.removeEventListener('touchmove', preventScroll)
// Restore scroll position
window.scrollTo(0, scrollY)
}
}, [mobileMenuOpen])
@@ -299,6 +335,30 @@ export default function Layout({ children }: LayoutProps) {
{/* Animated Background */}
<AnimatedBackground />
{/* Pull to refresh indicator */}
{(isPulling || isRefreshing) && (
<div
className="fixed left-1/2 -translate-x-1/2 z-[100] flex items-center justify-center transition-all duration-200"
style={{
top: `calc(${Math.max(pullDistance, isRefreshing ? 40 : 0)}px + env(safe-area-inset-top, 0px) + 0.5rem)`,
opacity: isRefreshing ? 1 : progress,
}}
>
<div className={`w-10 h-10 rounded-full bg-dark-800 border border-dark-700 shadow-lg flex items-center justify-center ${isRefreshing ? 'animate-pulse' : ''}`}>
<svg
className={`w-5 h-5 text-accent-400 transition-transform duration-200 ${isRefreshing ? 'animate-spin' : ''}`}
style={{ transform: isRefreshing ? undefined : `rotate(${progress * 360}deg)` }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</div>
</div>
)}
{/* Header */}
<header
className="sticky top-0 z-50 glass border-b border-dark-800/50"
@@ -307,10 +367,10 @@ export default function Layout({ children }: LayoutProps) {
paddingTop: isFullscreen ? `${Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px` : undefined,
}}
>
<div className="w-full mx-auto px-4 sm:px-6">
<div className="w-full mx-auto px-4 sm:px-6" onClick={() => mobileMenuOpen && setMobileMenuOpen(false)}>
<div className="flex justify-between items-center h-16 lg:h-20">
{/* Logo */}
<Link to="/" className={`flex items-center gap-2.5 flex-shrink-0 ${!appName ? 'lg:mr-4' : ''}`}>
<Link to="/" onClick={() => setMobileMenuOpen(false)} className={`flex items-center gap-2.5 flex-shrink-0 ${!appName ? 'lg:mr-4' : ''}`}>
<div className="w-10 h-10 sm:w-12 sm:h-12 lg:w-14 lg:h-14 rounded-xl bg-gradient-to-br from-accent-400 to-accent-600 flex items-center justify-center overflow-hidden shadow-lg shadow-accent-500/20 flex-shrink-0 relative">
{/* Always show letter as fallback */}
<span className={`text-white font-bold text-lg sm:text-xl lg:text-2xl absolute transition-opacity duration-200 ${hasCustomLogo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}>
@@ -372,24 +432,13 @@ export default function Layout({ children }: LayoutProps) {
{/* Right side */}
<div className="flex items-center gap-2 sm:gap-3">
{/* Fullscreen toggle - only show in Telegram WebApp */}
{isTelegramWebApp && isFullscreenSupported && (
<button
onClick={toggleFullscreen}
className="relative p-2.5 rounded-xl transition-all duration-300 hover:scale-110 active:scale-95
dark:text-dark-400 dark:hover:text-dark-100 dark:hover:bg-dark-800
text-champagne-500 hover:text-champagne-800 hover:bg-champagne-200/50"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
>
{isFullscreen ? <ExitFullscreenIcon /> : <FullscreenIcon />}
</button>
)}
{/* Theme toggle button - only show if both themes are enabled */}
{canToggle && (
<button
onClick={toggleTheme}
onClick={() => {
toggleTheme()
setMobileMenuOpen(false)
}}
className="relative p-2.5 rounded-xl transition-all duration-300 hover:scale-110 active:scale-95
dark:text-dark-400 dark:hover:text-dark-100 dark:hover:bg-dark-800
text-champagne-500 hover:text-champagne-800 hover:bg-champagne-200/50"
@@ -407,10 +456,14 @@ export default function Layout({ children }: LayoutProps) {
</button>
)}
<PromoDiscountBadge />
<TicketNotificationBell isAdmin={isAdminActive()} />
<div onClick={() => setMobileMenuOpen(false)}>
<PromoDiscountBadge />
</div>
<div onClick={() => setMobileMenuOpen(false)}>
<TicketNotificationBell isAdmin={isAdminActive()} />
</div>
{/* Hide language switcher on mobile when promo is active */}
<div className={isPromoActive ? 'hidden sm:block' : ''}>
<div className={isPromoActive ? 'hidden sm:block' : ''} onClick={() => setMobileMenuOpen(false)}>
<LanguageSwitcher />
</div>
@@ -439,7 +492,10 @@ export default function Layout({ children }: LayoutProps) {
{/* Mobile menu button */}
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
onClick={(e) => {
e.stopPropagation()
setMobileMenuOpen(!mobileMenuOpen)
}}
className="lg:hidden btn-icon"
aria-label={mobileMenuOpen ? t('common.close') || 'Close menu' : t('nav.menu') || 'Open menu'}
aria-expanded={mobileMenuOpen}
@@ -452,9 +508,16 @@ export default function Layout({ children }: LayoutProps) {
</header>
{/* Mobile menu - fixed overlay */}
{/* Mobile menu - fixed overlay below header */}
{mobileMenuOpen && (
<div className="lg:hidden fixed inset-0 z-40 animate-fade-in" style={{ top: '64px' }}>
<div
className="lg:hidden fixed inset-x-0 bottom-0 z-40 animate-fade-in"
style={{
top: isFullscreen
? `${64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px`
: '64px'
}}
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
@@ -462,7 +525,7 @@ export default function Layout({ children }: LayoutProps) {
/>
{/* Menu content */}
<div className="absolute inset-x-0 top-0 bottom-0 bg-dark-900 border-t border-dark-800/50 overflow-y-auto pb-[calc(5rem+env(safe-area-inset-bottom,0px))]">
<div className="mobile-menu-content absolute inset-x-0 top-0 bottom-0 bg-dark-900 border-t border-dark-800/50 overflow-y-auto overscroll-contain pb-[calc(5rem+env(safe-area-inset-bottom,0px))]" style={{ WebkitOverflowScrolling: 'touch' }}>
<div className="max-w-6xl mx-auto px-4 py-4">
{/* User info */}
<div className="flex items-center justify-between pb-4 mb-4 border-b border-dark-800/50">

View File

@@ -0,0 +1,101 @@
import { useEffect, useRef, useState, useCallback } from 'react'
interface UsePullToRefreshOptions {
onRefresh?: () => void | Promise<void>
threshold?: number // How far to pull before triggering refresh (px)
disabled?: boolean
}
export function usePullToRefresh({
onRefresh,
threshold = 80,
disabled = false,
}: UsePullToRefreshOptions = {}) {
const [isPulling, setIsPulling] = useState(false)
const [pullDistance, setPullDistance] = useState(0)
const [isRefreshing, setIsRefreshing] = useState(false)
const startY = useRef(0)
const currentY = useRef(0)
const handleRefresh = useCallback(async () => {
if (onRefresh) {
setIsRefreshing(true)
try {
await onRefresh()
} finally {
setIsRefreshing(false)
}
} else {
// Default: reload the page
window.location.reload()
}
}, [onRefresh])
useEffect(() => {
if (disabled) return
const handleTouchStart = (e: TouchEvent) => {
// Only trigger if at top of page
if (window.scrollY > 5) return
startY.current = e.touches[0].clientY
currentY.current = e.touches[0].clientY
}
const handleTouchMove = (e: TouchEvent) => {
if (startY.current === 0) return
if (window.scrollY > 5) {
// User scrolled down, reset
startY.current = 0
setPullDistance(0)
setIsPulling(false)
return
}
currentY.current = e.touches[0].clientY
const diff = currentY.current - startY.current
// Only track downward pulls
if (diff > 0) {
// Apply resistance - pull distance is less than actual finger movement
const resistance = 0.4
const distance = Math.min(diff * resistance, threshold * 1.5)
setPullDistance(distance)
setIsPulling(true)
// Prevent default scroll if we're pulling
if (distance > 10) {
e.preventDefault()
}
}
}
const handleTouchEnd = () => {
if (pullDistance >= threshold && !isRefreshing) {
handleRefresh()
}
startY.current = 0
setPullDistance(0)
setIsPulling(false)
}
document.addEventListener('touchstart', handleTouchStart, { passive: true })
document.addEventListener('touchmove', handleTouchMove, { passive: false })
document.addEventListener('touchend', handleTouchEnd, { passive: true })
return () => {
document.removeEventListener('touchstart', handleTouchStart)
document.removeEventListener('touchmove', handleTouchMove)
document.removeEventListener('touchend', handleTouchEnd)
}
}, [disabled, threshold, pullDistance, isRefreshing, handleRefresh])
return {
isPulling,
pullDistance,
isRefreshing,
progress: Math.min(pullDistance / threshold, 1),
}
}

View File

@@ -1,5 +1,25 @@
import { useEffect, useState, useCallback } from 'react'
const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled'
// Get cached fullscreen setting
export const getCachedFullscreenEnabled = (): boolean => {
try {
return localStorage.getItem(FULLSCREEN_CACHE_KEY) === 'true'
} catch {
return false
}
}
// Set cached fullscreen setting
export const setCachedFullscreenEnabled = (enabled: boolean) => {
try {
localStorage.setItem(FULLSCREEN_CACHE_KEY, String(enabled))
} catch {
// localStorage not available
}
}
/**
* Hook for Telegram WebApp API integration
* Provides fullscreen mode, safe area insets, and other WebApp features
@@ -123,5 +143,15 @@ export function initTelegramWebApp() {
if (webApp.disableVerticalSwipes) {
webApp.disableVerticalSwipes()
}
// Auto-enter fullscreen if enabled in settings (use cached value for instant response)
const fullscreenEnabled = getCachedFullscreenEnabled()
if (fullscreenEnabled && webApp.requestFullscreen && !webApp.isFullscreen) {
try {
webApp.requestFullscreen()
} catch (e) {
console.warn('Auto-fullscreen failed:', e)
}
}
}
}

View File

@@ -184,7 +184,9 @@
"yourDevice": "Your device",
"copyLink": "Copy subscription link",
"copied": "Link copied!",
"openLink": "Open link"
"openLink": "Open link",
"instructions": "Instructions",
"changeApp": "Change app"
},
"myDevices": "My Devices",
"noDevices": "No connected devices",

View File

@@ -184,7 +184,9 @@
"yourDevice": "Ваше устройство",
"copyLink": "Скопировать ссылку",
"copied": "Ссылка скопирована!",
"openLink": "Открыть ссылку"
"openLink": "Открыть ссылку",
"instructions": "Инструкция",
"changeApp": "Сменить приложение"
},
"myDevices": "Мои устройства",
"noDevices": "Нет подключенных устройств",

View File

@@ -5,6 +5,7 @@ import { Link } from 'react-router-dom'
import { adminSettingsApi, SettingDefinition, SettingCategorySummary } from '../api/adminSettings'
import { brandingApi, setCachedBranding } from '../api/branding'
import { setCachedAnimationEnabled } from '../components/AnimatedBackground'
import { setCachedFullscreenEnabled } from '../hooks/useTelegramWebApp'
import { themeColorsApi } from '../api/themeColors'
import { DEFAULT_THEME_COLORS, DEFAULT_ENABLED_THEMES } from '../types/theme'
import { ColorPicker } from '../components/ColorPicker'
@@ -880,6 +881,21 @@ export default function AdminSettings() {
},
})
// Fullscreen toggle query and mutation
const { data: fullscreenSettings } = useQuery({
queryKey: ['fullscreen-enabled'],
queryFn: brandingApi.getFullscreenEnabled,
})
const updateFullscreenMutation = useMutation({
mutationFn: (enabled: boolean) => brandingApi.updateFullscreenEnabled(enabled),
onSuccess: (data) => {
// Update local cache immediately for instant effect
setCachedFullscreenEnabled(data.enabled)
queryClient.invalidateQueries({ queryKey: ['fullscreen-enabled'] })
},
})
const updateNameMutation = useMutation({
mutationFn: (name: string) => brandingApi.updateName(name),
onSuccess: (data) => {
@@ -1229,6 +1245,29 @@ export default function AdminSettings() {
</button>
</div>
</div>
{/* Fullscreen Toggle */}
<div className="mt-4 pt-4 border-t border-dark-700/50">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-dark-200">Авто-Fullscreen</h3>
<p className="text-xs text-dark-500 mt-0.5">
Автоматически открывать на полный экран в Telegram
</p>
</div>
<button
onClick={() => updateFullscreenMutation.mutate(!(fullscreenSettings?.enabled ?? false))}
disabled={updateFullscreenMutation.isPending}
className={`relative w-12 h-6 rounded-full transition-colors ${
(fullscreenSettings?.enabled ?? false) ? 'bg-accent-500' : 'bg-dark-600'
} ${updateFullscreenMutation.isPending ? 'opacity-50' : ''}`}
>
<div className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
(fullscreenSettings?.enabled ?? false) ? 'left-7' : 'left-1'
}`} />
</button>
</div>
</div>
</div>
{/* Theme Colors Card */}

View File

@@ -68,6 +68,12 @@ export default function Referral() {
queryFn: referralApi.getReferralInfo,
})
// Build referral link using frontend env variable for correct bot username
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME
const referralLink = info?.referral_code && botUsername
? `https://t.me/${botUsername}?start=${info.referral_code}`
: info?.referral_link || ''
const { data: terms } = useQuery({
queryKey: ['referral-terms'],
queryFn: referralApi.getReferralTerms,
@@ -90,15 +96,15 @@ export default function Referral() {
})
const copyLink = () => {
if (info?.referral_link) {
navigator.clipboard.writeText(info.referral_link)
if (referralLink) {
navigator.clipboard.writeText(referralLink)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
const shareLink = () => {
if (!info?.referral_link) return
if (!referralLink) return
const shareText = t('referral.shareMessage', {
percent: info?.commission_percent || 0,
botName: branding?.name || import.meta.env.VITE_APP_NAME || 'Cabinet',
@@ -109,7 +115,7 @@ export default function Referral() {
.share({
title: t('referral.title'),
text: shareText,
url: info.referral_link,
url: referralLink,
})
.catch(() => {
// ignore cancellation errors
@@ -118,7 +124,7 @@ export default function Referral() {
}
const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(
info.referral_link
referralLink
)}&text=${encodeURIComponent(shareText)}`
window.open(telegramUrl, '_blank', 'noopener,noreferrer')
}
@@ -176,16 +182,16 @@ export default function Referral() {
<input
type='text'
readOnly
value={info?.referral_link || ''}
value={referralLink}
className='input flex-1'
/>
<div className='flex gap-2'>
<button
onClick={copyLink}
disabled={!info?.referral_link}
disabled={!referralLink}
className={`btn-primary px-5 ${
copied ? 'bg-success-500 hover:bg-success-500' : ''
} ${!info?.referral_link ? 'opacity-50 cursor-not-allowed' : ''}`}
} ${!referralLink ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{copied ? <CheckIcon /> : <CopyIcon />}
<span className='ml-2'>
@@ -194,9 +200,9 @@ export default function Referral() {
</button>
<button
onClick={shareLink}
disabled={!info?.referral_link}
disabled={!referralLink}
className={`btn-secondary px-5 flex items-center ${
!info?.referral_link ? 'opacity-50 cursor-not-allowed' : ''
!referralLink ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
<ShareIcon />