Refactor Dockerfile and Vite configuration to remove VITE_APP_VERSION and streamline build process

- Removed VITE_APP_VERSION from Dockerfile and GitHub workflows.
- Simplified Vite configuration by eliminating the Git version retrieval function and global constant for app version.
- Updated ColorPicker and other components to enhance accessibility with aria-labels.
- Introduced unique identifiers for buttons in the AppEditorModal for better React key management.
- Improved error handling in the Login component to provide user-friendly messages without exposing sensitive data.
This commit is contained in:
PEDZEO
2026-01-19 07:39:09 +03:00
parent 5409317501
commit 638f1d5456
21 changed files with 401 additions and 245 deletions

View File

@@ -62,4 +62,3 @@ jobs:
VITE_TELEGRAM_BOT_USERNAME= VITE_TELEGRAM_BOT_USERNAME=
VITE_APP_NAME=Cabinet VITE_APP_NAME=Cabinet
VITE_APP_LOGO=V VITE_APP_LOGO=V
VITE_APP_VERSION=${{ github.ref_name }}

View File

@@ -34,7 +34,6 @@ jobs:
VITE_TELEGRAM_BOT_USERNAME: VITE_TELEGRAM_BOT_USERNAME:
VITE_APP_NAME: Bedolaga Cabinet VITE_APP_NAME: Bedolaga Cabinet
VITE_APP_LOGO: V VITE_APP_LOGO: V
VITE_APP_VERSION: ${{ github.ref_name }}
- name: Create dist archive - name: Create dist archive
run: | run: |

View File

@@ -7,7 +7,6 @@ WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
# Install dependencies # Install dependencies
# Use npm install if no lock file, npm ci if lock file exists
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
# Copy source code # Copy source code
@@ -18,14 +17,12 @@ ARG VITE_API_URL=/api
ARG VITE_TELEGRAM_BOT_USERNAME ARG VITE_TELEGRAM_BOT_USERNAME
ARG VITE_APP_NAME=Cabinet ARG VITE_APP_NAME=Cabinet
ARG VITE_APP_LOGO=V ARG VITE_APP_LOGO=V
ARG VITE_APP_VERSION
# Set environment variables for build # Set environment variables for build
ENV VITE_API_URL=$VITE_API_URL ENV VITE_API_URL=$VITE_API_URL
ENV VITE_TELEGRAM_BOT_USERNAME=$VITE_TELEGRAM_BOT_USERNAME ENV VITE_TELEGRAM_BOT_USERNAME=$VITE_TELEGRAM_BOT_USERNAME
ENV VITE_APP_NAME=$VITE_APP_NAME ENV VITE_APP_NAME=$VITE_APP_NAME
ENV VITE_APP_LOGO=$VITE_APP_LOGO ENV VITE_APP_LOGO=$VITE_APP_LOGO
ENV VITE_APP_VERSION=$VITE_APP_VERSION
# Build the application # Build the application
RUN npm run build RUN npm run build

View File

@@ -9,6 +9,7 @@ export interface LocalizedText {
} }
export interface AppButton { export interface AppButton {
id?: string // Unique identifier for React key (client-side only)
buttonLink: string buttonLink: string
buttonText: LocalizedText buttonText: LocalizedText
} }

View File

@@ -98,6 +98,22 @@ export interface WheelPrizeAdmin {
updated_at: string | null updated_at: string | null
} }
// Type for creating a new prize (excludes id, config_id which are auto-generated)
export interface CreateWheelPrizeData {
prize_type: string
prize_value: number
display_name: string
emoji?: string
color?: string
prize_value_kopeks: number
sort_order?: number
manual_probability?: number | null
is_active?: boolean
promo_balance_bonus_kopeks?: number
promo_subscription_days?: number
promo_traffic_gb?: number
}
export interface AdminWheelConfig { export interface AdminWheelConfig {
id: number id: number
is_enabled: boolean is_enabled: boolean
@@ -223,20 +239,7 @@ export const adminWheelApi = {
}, },
// Create prize // Create prize
createPrize: async (data: { createPrize: async (data: CreateWheelPrizeData): Promise<WheelPrizeAdmin> => {
prize_type: string
prize_value: number
display_name: string
emoji?: string
color?: string
prize_value_kopeks: number
sort_order?: number
manual_probability?: number | null
is_active?: boolean
promo_balance_bonus_kopeks?: number
promo_subscription_days?: number
promo_traffic_gb?: number
}): Promise<WheelPrizeAdmin> => {
const response = await apiClient.post<WheelPrizeAdmin>('/cabinet/admin/wheel/prizes', data) const response = await apiClient.post<WheelPrizeAdmin>('/cabinet/admin/wheel/prizes', data)
return response.data return response.data
}, },

View File

@@ -81,14 +81,15 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
{description && <p className="text-xs text-dark-500 mb-2">{description}</p>} {description && <p className="text-xs text-dark-500 mb-2">{description}</p>}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Color preview button */} {/* Color preview button - min 44px for touch accessibility */}
<button <button
type="button" type="button"
onClick={() => !disabled && setIsOpen(!isOpen)} onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled} disabled={disabled}
className="w-10 h-10 rounded-lg border-2 border-dark-700 shadow-inner transition-all hover:scale-105 hover:border-dark-600 disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0" className="w-11 h-11 rounded-lg border-2 border-dark-700 shadow-inner transition-all hover:scale-105 hover:border-dark-600 disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0"
style={{ backgroundColor: localValue || '#000000' }} style={{ backgroundColor: localValue || '#000000' }}
title={localValue} title={localValue}
aria-label={`Select color: ${localValue}`}
/> />
{/* Hex input */} {/* Hex input */}
@@ -112,13 +113,14 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
className="sr-only" className="sr-only"
/> />
{/* Native picker button */} {/* Native picker button - min 44px for touch accessibility */}
<button <button
type="button" type="button"
onClick={() => colorInputRef.current?.click()} onClick={() => colorInputRef.current?.click()}
disabled={disabled} disabled={disabled}
className="btn-secondary p-2 disabled:opacity-50" className="btn-secondary min-w-[44px] min-h-[44px] p-2.5 disabled:opacity-50"
title="Open color picker" title="Open color picker"
aria-label="Open color picker"
> >
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path <path
@@ -134,16 +136,17 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
{isOpen && ( {isOpen && (
<div className="absolute z-50 mt-2 p-3 bg-dark-800 rounded-xl border border-dark-700 shadow-xl animate-fade-in"> <div className="absolute z-50 mt-2 p-3 bg-dark-800 rounded-xl border border-dark-700 shadow-xl animate-fade-in">
<div className="text-xs text-dark-400 mb-2">Preset colors</div> <div className="text-xs text-dark-400 mb-2">Preset colors</div>
<div className="grid grid-cols-6 gap-2"> <div className="grid grid-cols-4 sm:grid-cols-6 gap-1">
{PRESET_COLORS.map((preset) => ( {PRESET_COLORS.map((preset) => (
<button <button
key={preset} key={preset}
onClick={() => handlePresetClick(preset)} onClick={() => handlePresetClick(preset)}
className={`w-7 h-7 rounded-lg border-2 transition-all hover:scale-110 ${ className={`min-w-[44px] min-h-[44px] w-11 h-11 rounded-lg border-2 transition-all hover:scale-105 ${
localValue === preset ? 'border-white' : 'border-dark-600 hover:border-dark-500' localValue === preset ? 'border-white ring-2 ring-white/30' : 'border-dark-600 hover:border-dark-500'
}`} }`}
style={{ backgroundColor: preset }} style={{ backgroundColor: preset }}
title={preset} title={preset}
aria-label={`Select color ${preset}`}
/> />
))} ))}
</div> </div>

View File

@@ -286,7 +286,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
<h2 className="text-lg font-semibold text-dark-100"> <h2 className="text-lg font-semibold text-dark-100">
{t('subscription.connection.title')} {t('subscription.connection.title')}
</h2> </h2>
<button onClick={onClose} className="btn-icon"> <button onClick={onClose} className="btn-icon" aria-label="Close">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>

View File

@@ -152,7 +152,7 @@ function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethod
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-dark-800/50"> <div className="flex items-center justify-between px-4 py-3 bg-dark-800/50">
<span className="font-semibold text-dark-100">{t('balance.selectPaymentMethod')}</span> <span className="font-semibold text-dark-100">{t('balance.selectPaymentMethod')}</span>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-dark-700 text-dark-400"> <button onClick={onClose} className="p-1.5 rounded-lg hover:bg-dark-700 text-dark-400" aria-label="Close">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>

View File

@@ -179,9 +179,9 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
> >
{/* Progress indicator */} {/* Progress indicator */}
<div className="flex items-center gap-1.5 mb-4"> <div className="flex items-center gap-1.5 mb-4">
{steps.map((_, index) => ( {steps.map((s, index) => (
<div <div
key={index} key={s.target}
className={`h-1 rounded-full transition-all duration-300 ${ className={`h-1 rounded-full transition-all duration-300 ${
index === currentStep index === currentStep
? 'w-6 bg-accent-500' ? 'w-6 bg-accent-500'

View File

@@ -9,23 +9,55 @@ import type { PaymentMethod } from '../types'
const TELEGRAM_LINK_REGEX = /^https?:\/\/t\.me\//i const TELEGRAM_LINK_REGEX = /^https?:\/\/t\.me\//i
const isTelegramPaymentLink = (url: string): boolean => TELEGRAM_LINK_REGEX.test(url) const isTelegramPaymentLink = (url: string): boolean => TELEGRAM_LINK_REGEX.test(url)
const openPaymentLink = (url: string, reservedWindow?: Window | null) => { /**
if (typeof window === 'undefined' || !url) return * Открывает платёжную ссылку разными способами в зависимости от окружения.
* Возвращает true если ссылка успешно открыта, false если все методы не сработали.
*/
const openPaymentLink = (url: string, reservedWindow?: Window | null): boolean => {
if (typeof window === 'undefined' || !url) return false
const webApp = window.Telegram?.WebApp const webApp = window.Telegram?.WebApp
// Попытка 1: Telegram openTelegramLink для t.me ссылок
if (isTelegramPaymentLink(url) && webApp?.openTelegramLink) { if (isTelegramPaymentLink(url) && webApp?.openTelegramLink) {
try { webApp.openTelegramLink(url); return } catch (e) { console.warn('[TopUpModal] openTelegramLink failed:', e) } try {
webApp.openTelegramLink(url)
return true
} catch (e) {
console.warn('[TopUpModal] openTelegramLink failed:', e)
} }
}
// Попытка 2: Telegram WebApp openLink
if (webApp?.openLink) { if (webApp?.openLink) {
try { webApp.openLink(url, { try_instant_view: false }); return } catch (e) { console.warn('[TopUpModal] webApp.openLink failed:', e) } try {
webApp.openLink(url, { try_instant_view: false })
return true
} catch (e) {
console.warn('[TopUpModal] webApp.openLink failed:', e)
} }
}
// Попытка 3: Зарезервированное окно браузера
if (reservedWindow && !reservedWindow.closed) { if (reservedWindow && !reservedWindow.closed) {
try { reservedWindow.location.href = url; reservedWindow.focus?.() } catch (e) { console.warn('[TopUpModal] Failed to use reserved window:', e) } try {
return reservedWindow.location.href = url
reservedWindow.focus?.()
return true
} catch (e) {
console.warn('[TopUpModal] Failed to use reserved window:', e)
} }
}
// Попытка 4: window.open
const w2 = window.open(url, '_blank', 'noopener,noreferrer') const w2 = window.open(url, '_blank', 'noopener,noreferrer')
if (w2) { w2.opener = null; return } if (w2) {
w2.opener = null
return true
}
// Последний вариант: редирект текущей страницы
window.location.href = url window.location.href = url
return true
} }
interface TopUpModalProps { interface TopUpModalProps {
@@ -82,24 +114,47 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
}) })
const topUpMutation = useMutation<{ const topUpMutation = useMutation<{
payment_id: string; payment_url?: string; invoice_url?: string payment_id: string
amount_kopeks: number; amount_rubles: number; status: string; expires_at: string | null payment_url?: string
invoice_url?: string
amount_kopeks: number
amount_rubles: number
status: string
expires_at: string | null
}, unknown, number>({ }, unknown, number>({
mutationFn: (amountKopeks: number) => balanceApi.createTopUp(amountKopeks, method.id, selectedOption || undefined), mutationFn: (amountKopeks: number) => balanceApi.createTopUp(amountKopeks, method.id, selectedOption || undefined),
onSuccess: (data) => { onSuccess: (data) => {
const redirectUrl = data.payment_url || (data as any).invoice_url const redirectUrl = data.payment_url || data.invoice_url
if (redirectUrl) openPaymentLink(redirectUrl, popupRef.current) if (!redirectUrl) {
setError(t('balance.noPaymentUrl', 'Сервер не вернул ссылку для оплаты'))
closePopupSafely()
return
}
const opened = openPaymentLink(redirectUrl, popupRef.current)
if (!opened) {
setError(t('balance.openLinkFailed', 'Не удалось открыть страницу оплаты'))
}
popupRef.current = null popupRef.current = null
onClose() onClose()
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
try { if (popupRef.current && !popupRef.current.closed) popupRef.current.close() } catch {} closePopupSafely()
popupRef.current = null
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '' const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || ''
setError(detail.includes('not yet implemented') ? t('balance.useBot') : (detail || t('common.error'))) setError(detail.includes('not yet implemented') ? t('balance.useBot') : (detail || t('common.error')))
}, },
}) })
const closePopupSafely = () => {
try {
if (popupRef.current && !popupRef.current.closed) {
popupRef.current.close()
}
} catch (e) {
console.warn('[TopUpModal] Failed to close popup:', e)
}
popupRef.current = null
}
const handleSubmit = () => { const handleSubmit = () => {
setError(null) setError(null)
inputRef.current?.blur() inputRef.current?.blur()
@@ -139,7 +194,7 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-dark-800/50"> <div className="flex items-center justify-between px-4 py-3 bg-dark-800/50">
<span className="font-semibold text-dark-100">{methodName}</span> <span className="font-semibold text-dark-100">{methodName}</span>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-dark-700 text-dark-400"> <button onClick={onClose} className="p-1.5 rounded-lg hover:bg-dark-700 text-dark-400" aria-label="Close">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>

View File

@@ -337,6 +337,7 @@ export default function Layout({ children }: LayoutProps) {
dark:text-dark-400 dark:hover:text-dark-100 dark:hover:bg-dark-800 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" text-champagne-500 hover:text-champagne-800 hover:bg-champagne-200/50"
title={isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'} title={isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'}
aria-label={isDark ? t('theme.light') || 'Switch to light mode' : t('theme.dark') || 'Switch to dark mode'}
> >
<div className="relative w-5 h-5"> <div className="relative w-5 h-5">
<div className={`absolute inset-0 transition-all duration-300 ${isDark ? 'opacity-100 rotate-0' : 'opacity-0 rotate-90'}`}> <div className={`absolute inset-0 transition-all duration-300 ${isDark ? 'opacity-100 rotate-0' : 'opacity-0 rotate-90'}`}>
@@ -373,6 +374,7 @@ export default function Layout({ children }: LayoutProps) {
onClick={logout} onClick={logout}
className="btn-icon" className="btn-icon"
title={t('nav.logout')} title={t('nav.logout')}
aria-label={t('nav.logout') || 'Logout'}
> >
<LogoutIcon /> <LogoutIcon />
</button> </button>
@@ -382,6 +384,8 @@ export default function Layout({ children }: LayoutProps) {
<button <button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)} onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="lg:hidden btn-icon" className="lg:hidden btn-icon"
aria-label={mobileMenuOpen ? t('common.close') || 'Close menu' : t('nav.menu') || 'Open menu'}
aria-expanded={mobileMenuOpen}
> >
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />} {mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
</button> </button>
@@ -500,14 +504,6 @@ export default function Layout({ children }: LayoutProps) {
{children} {children}
</div> </div>
{/* Version footer for admin pages */}
{isAdminActive() && (
<div className="mt-8 pt-4 border-t border-dark-800/50 text-center">
<span className="text-xs text-dark-500">
Cabinet {__APP_VERSION__}
</span>
</div>
)}
</main> </main>
{/* Mobile Bottom Navigation - only core items */} {/* Mobile Bottom Navigation - only core items */}

View File

@@ -189,7 +189,13 @@ function AppEditorModal({ app, platform, isNew, onSave, onClose }: AppEditorModa
const addButton = (stepKey: 'installationStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep') => { const addButton = (stepKey: 'installationStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep') => {
const step = editedApp[stepKey] || emptyAppStep() const step = editedApp[stepKey] || emptyAppStep()
const buttons = step.buttons || [] const buttons = step.buttons || []
updateButtons(stepKey, [...buttons, { buttonLink: '', buttonText: emptyLocalizedText() }]) // 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 removeButton = (stepKey: 'installationStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep', index: number) => {
@@ -253,7 +259,7 @@ function AppEditorModal({ app, platform, isNew, onSave, onClose }: AppEditorModa
</button> </button>
</div> </div>
{buttons.map((button, index) => ( {buttons.map((button, index) => (
<div key={index} className="p-3 bg-dark-800/50 rounded-lg space-y-2"> <div key={button.id || `fallback-${index}`} className="p-3 bg-dark-800/50 rounded-lg space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-dark-400">{t('admin.apps.button')} #{index + 1}</span> <span className="text-xs text-dark-400">{t('admin.apps.button')} #{index + 1}</span>
<button <button

View File

@@ -190,14 +190,14 @@ function RevenueChart({ data }: { data: { date: string; amount_rubles: number }[
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{last7Days.map((item, index) => { {last7Days.map((item) => {
const percentage = (item.amount_rubles / maxValue) * 100 const percentage = (item.amount_rubles / maxValue) * 100
const date = new Date(item.date) const date = new Date(item.date)
const dayName = date.toLocaleDateString('ru-RU', { weekday: 'short' }) const dayName = date.toLocaleDateString('ru-RU', { weekday: 'short' })
const dayNum = date.getDate() const dayNum = date.getDate()
return ( return (
<div key={index} className="group"> <div key={item.date} className="group">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<span className="text-sm text-dark-300 font-medium capitalize">{dayName}, {dayNum}</span> <span className="text-sm text-dark-300 font-medium capitalize">{dayName}, {dayNum}</span>
<span className="text-sm font-semibold text-dark-100">{formatAmount(item.amount_rubles)} {currencySymbol}</span> <span className="text-sm font-semibold text-dark-100">{formatAmount(item.amount_rubles)} {currencySymbol}</span>

View File

@@ -154,10 +154,22 @@ function DesktopAdminCard({ to, icon, title, description, bgColor, textColor }:
) )
} }
// Section group definition
interface SectionGroup {
title: string
emoji: string
sections: AdminSection[]
}
export default function AdminPanel() { export default function AdminPanel() {
const { t } = useTranslation() const { t } = useTranslation()
const adminSections: AdminSection[] = [ // Grouped admin sections
const sectionGroups: SectionGroup[] = [
{
title: 'Аналитика',
emoji: '📊',
sections: [
{ {
to: '/admin/dashboard', to: '/admin/dashboard',
icon: <ChartIcon />, icon: <ChartIcon />,
@@ -168,6 +180,32 @@ export default function AdminPanel() {
bgColor: 'bg-emerald-500/20', bgColor: 'bg-emerald-500/20',
textColor: 'text-emerald-400' textColor: 'text-emerald-400'
}, },
{
to: '/admin/payments',
icon: <PaymentsIcon />,
mobileIcon: <PaymentsIcon className="w-6 h-6" />,
title: t('admin.nav.payments', 'Платежи'),
description: t('admin.panel.paymentsDesc', 'Проверка платежей'),
color: 'lime',
bgColor: 'bg-lime-500/20',
textColor: 'text-lime-400'
},
]
},
{
title: 'Пользователи',
emoji: '👥',
sections: [
{
to: '/admin/users',
icon: <UsersIcon />,
mobileIcon: <UsersIcon className="w-6 h-6" />,
title: t('admin.nav.users', 'Пользователи'),
description: t('admin.panel.usersDesc', 'Управление пользователями'),
color: 'indigo',
bgColor: 'bg-indigo-500/20',
textColor: 'text-indigo-400'
},
{ {
to: '/admin/tickets', to: '/admin/tickets',
icon: <TicketIcon />, icon: <TicketIcon />,
@@ -179,35 +217,21 @@ export default function AdminPanel() {
textColor: 'text-amber-400' textColor: 'text-amber-400'
}, },
{ {
to: '/admin/settings', to: '/admin/ban-system',
icon: <CogIcon />, icon: <BanSystemIcon />,
mobileIcon: <CogIcon className="w-6 h-6" />, mobileIcon: <BanSystemIcon className="w-6 h-6" />,
title: t('admin.nav.settings'), title: t('admin.nav.banSystem'),
description: t('admin.panel.settingsDesc'), description: t('admin.panel.banSystemDesc'),
color: 'accent',
bgColor: 'bg-blue-500/20',
textColor: 'text-blue-400'
},
{
to: '/admin/apps',
icon: <PhoneIcon />,
mobileIcon: <PhoneIcon className="w-6 h-6" />,
title: t('admin.nav.apps'),
description: t('admin.panel.appsDesc'),
color: 'success',
bgColor: 'bg-teal-500/20',
textColor: 'text-teal-400'
},
{
to: '/admin/wheel',
icon: <WheelIcon />,
mobileIcon: <WheelIcon className="w-6 h-6" />,
title: t('admin.nav.wheel'),
description: t('admin.panel.wheelDesc'),
color: 'error', color: 'error',
bgColor: 'bg-rose-500/20', bgColor: 'bg-red-500/20',
textColor: 'text-rose-400' textColor: 'text-red-400'
}, },
]
},
{
title: 'Тарифы и продажи',
emoji: '💰',
sections: [
{ {
to: '/admin/tariffs', to: '/admin/tariffs',
icon: <TariffIcon />, icon: <TariffIcon />,
@@ -218,26 +242,6 @@ export default function AdminPanel() {
bgColor: 'bg-cyan-500/20', bgColor: 'bg-cyan-500/20',
textColor: 'text-cyan-400' textColor: 'text-cyan-400'
}, },
{
to: '/admin/servers',
icon: <ServerIcon />,
mobileIcon: <ServerIcon className="w-6 h-6" />,
title: t('admin.nav.servers'),
description: t('admin.panel.serversDesc'),
color: 'purple',
bgColor: 'bg-purple-500/20',
textColor: 'text-purple-400'
},
{
to: '/admin/broadcasts',
icon: <BroadcastIcon />,
mobileIcon: <BroadcastIcon className="w-6 h-6" />,
title: t('admin.nav.broadcasts'),
description: t('admin.panel.broadcastsDesc'),
color: 'orange',
bgColor: 'bg-orange-500/20',
textColor: 'text-orange-400'
},
{ {
to: '/admin/promocodes', to: '/admin/promocodes',
icon: <PromocodeIcon />, icon: <PromocodeIcon />,
@@ -258,6 +262,12 @@ export default function AdminPanel() {
bgColor: 'bg-orange-500/20', bgColor: 'bg-orange-500/20',
textColor: 'text-orange-400' textColor: 'text-orange-400'
}, },
]
},
{
title: 'Маркетинг',
emoji: '📣',
sections: [
{ {
to: '/admin/campaigns', to: '/admin/campaigns',
icon: <CampaignIcon />, icon: <CampaignIcon />,
@@ -269,34 +279,60 @@ export default function AdminPanel() {
textColor: 'text-orange-400' textColor: 'text-orange-400'
}, },
{ {
to: '/admin/users', to: '/admin/broadcasts',
icon: <UsersIcon />, icon: <BroadcastIcon />,
mobileIcon: <UsersIcon className="w-6 h-6" />, mobileIcon: <BroadcastIcon className="w-6 h-6" />,
title: t('admin.nav.users', 'Пользователи'), title: t('admin.nav.broadcasts'),
description: t('admin.panel.usersDesc', 'Управление пользователями'), description: t('admin.panel.broadcastsDesc'),
color: 'indigo', color: 'orange',
bgColor: 'bg-indigo-500/20', bgColor: 'bg-orange-500/20',
textColor: 'text-indigo-400' textColor: 'text-orange-400'
}, },
{ {
to: '/admin/ban-system', to: '/admin/wheel',
icon: <BanSystemIcon />, icon: <WheelIcon />,
mobileIcon: <BanSystemIcon className="w-6 h-6" />, mobileIcon: <WheelIcon className="w-6 h-6" />,
title: t('admin.nav.banSystem'), title: t('admin.nav.wheel'),
description: t('admin.panel.banSystemDesc'), description: t('admin.panel.wheelDesc'),
color: 'error', color: 'error',
bgColor: 'bg-red-500/20', bgColor: 'bg-rose-500/20',
textColor: 'text-red-400' textColor: 'text-rose-400'
},
]
}, },
{ {
to: '/admin/payments', title: 'Система',
icon: <PaymentsIcon />, emoji: '⚙️',
mobileIcon: <PaymentsIcon className="w-6 h-6" />, sections: [
title: t('admin.nav.payments', 'Платежи'), {
description: t('admin.panel.paymentsDesc', 'Проверка платежей'), to: '/admin/settings',
color: 'lime', icon: <CogIcon />,
bgColor: 'bg-lime-500/20', mobileIcon: <CogIcon className="w-6 h-6" />,
textColor: 'text-lime-400' title: t('admin.nav.settings'),
description: t('admin.panel.settingsDesc'),
color: 'accent',
bgColor: 'bg-blue-500/20',
textColor: 'text-blue-400'
},
{
to: '/admin/apps',
icon: <PhoneIcon />,
mobileIcon: <PhoneIcon className="w-6 h-6" />,
title: t('admin.nav.apps'),
description: t('admin.panel.appsDesc'),
color: 'success',
bgColor: 'bg-teal-500/20',
textColor: 'text-teal-400'
},
{
to: '/admin/servers',
icon: <ServerIcon />,
mobileIcon: <ServerIcon className="w-6 h-6" />,
title: t('admin.nav.servers'),
description: t('admin.panel.serversDesc'),
color: 'purple',
bgColor: 'bg-purple-500/20',
textColor: 'text-purple-400'
}, },
{ {
to: '/admin/remnawave', to: '/admin/remnawave',
@@ -309,6 +345,11 @@ export default function AdminPanel() {
textColor: 'text-purple-400' textColor: 'text-purple-400'
}, },
] ]
},
]
// Flatten all sections for mobile view
const allSections = sectionGroups.flatMap(group => group.sections)
return ( return (
<div className="animate-fade-in"> <div className="animate-fade-in">
@@ -323,19 +364,32 @@ export default function AdminPanel() {
</div> </div>
</div> </div>
{/* Mobile: Compact 2-column grid */} {/* Mobile: Compact grid (all sections) */}
<div className="grid grid-cols-3 gap-3 sm:hidden"> <div className="grid grid-cols-3 gap-3 sm:hidden">
{adminSections.map((section) => ( {allSections.map((section) => (
<MobileAdminCard key={section.to} {...section} /> <MobileAdminCard key={section.to} {...section} />
))} ))}
</div> </div>
{/* Tablet/Desktop: List style */} {/* Tablet/Desktop: Grouped sections */}
<div className="hidden sm:grid sm:grid-cols-1 lg:grid-cols-2 gap-3"> <div className="hidden sm:block space-y-6">
{adminSections.map((section) => ( {sectionGroups.map((group) => (
<div key={group.title}>
{/* Group header */}
<div className="flex items-center gap-2 mb-3">
<span className="text-xl">{group.emoji}</span>
<h2 className="text-sm font-semibold text-dark-400 uppercase tracking-wider">{group.title}</h2>
</div>
{/* Group cards */}
<div className="grid sm:grid-cols-1 lg:grid-cols-2 gap-3">
{group.sections.map((section) => (
<DesktopAdminCard key={section.to} {...section} /> <DesktopAdminCard key={section.to} {...section} />
))} ))}
</div> </div>
</div> </div>
))}
</div>
</div>
) )
} }

View File

@@ -2,7 +2,7 @@ import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { adminWheelApi, type WheelPrizeAdmin } from '../api/wheel' import { adminWheelApi, type WheelPrizeAdmin, type CreateWheelPrizeData } from '../api/wheel'
// Icons // Icons
const BackIcon = () => ( const BackIcon = () => (
@@ -468,7 +468,7 @@ export default function AdminWheel() {
if (editingPrize) { if (editingPrize) {
updatePrizeMutation.mutate({ id: editingPrize.id, data }) updatePrizeMutation.mutate({ id: editingPrize.id, data })
} else { } else {
createPrizeMutation.mutate(data as any) createPrizeMutation.mutate(data as CreateWheelPrizeData)
} }
}} }}
/> />

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback, useRef } from 'react'
import { useSearchParams, useNavigate } from 'react-router-dom' import { useSearchParams, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
@@ -46,6 +46,8 @@ export default function DeepLinkRedirect() {
const [status, setStatus] = useState<Status>('countdown') const [status, setStatus] = useState<Status>('countdown')
const [countdown, setCountdown] = useState(COUNTDOWN_SECONDS) const [countdown, setCountdown] = useState(COUNTDOWN_SECONDS)
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const fallbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Get branding // Get branding
const { data: branding } = useQuery({ const { data: branding } = useQuery({
@@ -135,23 +137,34 @@ export default function DeepLinkRedirect() {
if (prev <= 1) { if (prev <= 1) {
clearInterval(timer) clearInterval(timer)
openDeepLink() openDeepLink()
// Show fallback after a delay // Show fallback after a delay - store ref for cleanup
setTimeout(() => setStatus('fallback'), 2000) fallbackTimeoutRef.current = setTimeout(() => setStatus('fallback'), 2000)
return 0 return 0
} }
return prev - 1 return prev - 1
}) })
}, 1000) }, 1000)
return () => clearInterval(timer) return () => {
clearInterval(timer)
// Cleanup fallback timeout on unmount
if (fallbackTimeoutRef.current) {
clearTimeout(fallbackTimeoutRef.current)
fallbackTimeoutRef.current = null
}
}
}, [deepLink, status, openDeepLink]) }, [deepLink, status, openDeepLink])
const handleCopyLink = async () => { const handleCopyLink = async () => {
const linkToCopy = subscriptionUrl || deepLink const linkToCopy = subscriptionUrl || deepLink
// Clear previous timeout to prevent stacking
if (copiedTimeoutRef.current) {
clearTimeout(copiedTimeoutRef.current)
}
try { try {
await navigator.clipboard.writeText(linkToCopy) await navigator.clipboard.writeText(linkToCopy)
setCopied(true) setCopied(true)
setTimeout(() => setCopied(false), 2000) copiedTimeoutRef.current = setTimeout(() => setCopied(false), 2000)
} catch { } catch {
const textarea = document.createElement('textarea') const textarea = document.createElement('textarea')
textarea.value = linkToCopy textarea.value = linkToCopy
@@ -160,10 +173,19 @@ export default function DeepLinkRedirect() {
document.execCommand('copy') document.execCommand('copy')
document.body.removeChild(textarea) document.body.removeChild(textarea)
setCopied(true) setCopied(true)
setTimeout(() => setCopied(false), 2000) copiedTimeoutRef.current = setTimeout(() => setCopied(false), 2000)
} }
} }
// Cleanup copied timeout on unmount
useEffect(() => {
return () => {
if (copiedTimeoutRef.current) {
clearTimeout(copiedTimeoutRef.current)
}
}
}, [])
// Progress percentage // Progress percentage
const progress = ((COUNTDOWN_SECONDS - countdown) / COUNTDOWN_SECONDS) * 100 const progress = ((COUNTDOWN_SECONDS - countdown) / COUNTDOWN_SECONDS) * 100

View File

@@ -117,7 +117,9 @@ export default function Login() {
await loginWithTelegram(tg.initData) await loginWithTelegram(tg.initData)
navigate(getReturnUrl(), { replace: true }) navigate(getReturnUrl(), { replace: true })
} catch (err) { } catch (err) {
console.error('Telegram auth failed:', err) // Log only status code to avoid leaking sensitive data
const status = (err as { response?: { status?: number } })?.response?.status
console.warn('Telegram auth failed with status:', status)
setError(t('auth.telegramRequired')) setError(t('auth.telegramRequired'))
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@@ -137,8 +139,16 @@ export default function Login() {
await loginWithEmail(email, password) await loginWithEmail(email, password)
navigate(getReturnUrl(), { replace: true }) navigate(getReturnUrl(), { replace: true })
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { status?: number; data?: { detail?: string } } }
setError(error.response?.data?.detail || t('common.error')) // Show user-friendly error messages without exposing sensitive server details
const status = error.response?.status
if (status === 401 || status === 403) {
setError(t('auth.invalidCredentials', 'Неверный email или пароль'))
} else if (status === 429) {
setError(t('auth.tooManyAttempts', 'Слишком много попыток. Попробуйте позже'))
} else {
setError(t('common.error'))
}
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }

View File

@@ -1,4 +1,4 @@
import { useState, useRef } from 'react' import { useState, useRef, useCallback, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ticketsApi } from '../api/tickets' import { ticketsApi } from '../api/tickets'
@@ -91,6 +91,7 @@ function MessageMedia({ message, t }: { message: TicketMessage; t: (key: string)
<button <button
className="absolute top-4 right-4 text-white/70 hover:text-white" className="absolute top-4 right-4 text-white/70 hover:text-white"
onClick={() => setShowFullImage(false)} onClick={() => setShowFullImage(false)}
aria-label="Close fullscreen"
> >
<CloseIcon /> <CloseIcon />
</button> </button>
@@ -141,6 +142,29 @@ export default function Support() {
const createFileInputRef = useRef<HTMLInputElement>(null) const createFileInputRef = useRef<HTMLInputElement>(null)
const replyFileInputRef = useRef<HTMLInputElement>(null) const replyFileInputRef = useRef<HTMLInputElement>(null)
// Cleanup function to revoke object URLs and prevent memory leaks
const clearAttachment = useCallback((
attachment: MediaAttachment | null,
setAttachment: (a: MediaAttachment | null) => void
) => {
if (attachment?.preview) {
URL.revokeObjectURL(attachment.preview)
}
setAttachment(null)
}, [])
// Cleanup on unmount to prevent memory leaks
useEffect(() => {
return () => {
if (createAttachment?.preview) {
URL.revokeObjectURL(createAttachment.preview)
}
if (replyAttachment?.preview) {
URL.revokeObjectURL(replyAttachment.preview)
}
}
}, []) // Empty deps - only run on unmount
// Get support configuration // Get support configuration
const { data: supportConfig, isLoading: configLoading } = useQuery({ const { data: supportConfig, isLoading: configLoading } = useQuery({
queryKey: ['support-config'], queryKey: ['support-config'],
@@ -162,8 +186,14 @@ export default function Support() {
// Handle file selection // Handle file selection
const handleFileSelect = async ( const handleFileSelect = async (
file: File, file: File,
setAttachment: (a: MediaAttachment | null) => void setAttachment: (a: MediaAttachment | null) => void,
currentAttachment: MediaAttachment | null
) => { ) => {
// Revoke old object URL before creating new one
if (currentAttachment?.preview) {
URL.revokeObjectURL(currentAttachment.preview)
}
// Validate file type // Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
if (!allowedTypes.includes(file.type)) { if (!allowedTypes.includes(file.type)) {
@@ -224,7 +254,7 @@ export default function Support() {
setShowCreateForm(false) setShowCreateForm(false)
setNewTitle('') setNewTitle('')
setNewMessage('') setNewMessage('')
setCreateAttachment(null) clearAttachment(createAttachment, setCreateAttachment)
setSelectedTicket(ticket) setSelectedTicket(ticket)
}, },
}) })
@@ -242,7 +272,7 @@ export default function Support() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ticket', selectedTicket?.id] }) queryClient.invalidateQueries({ queryKey: ['ticket', selectedTicket?.id] })
setReplyMessage('') setReplyMessage('')
setReplyAttachment(null) clearAttachment(replyAttachment, setReplyAttachment)
}, },
}) })
@@ -443,7 +473,7 @@ export default function Support() {
onClick={() => { onClick={() => {
setShowCreateForm(true) setShowCreateForm(true)
setSelectedTicket(null) setSelectedTicket(null)
setCreateAttachment(null) clearAttachment(createAttachment, setCreateAttachment)
}} }}
className="btn-primary" className="btn-primary"
> >
@@ -469,7 +499,7 @@ export default function Support() {
onClick={() => { onClick={() => {
setSelectedTicket(ticket as unknown as TicketDetail) setSelectedTicket(ticket as unknown as TicketDetail)
setShowCreateForm(false) setShowCreateForm(false)
setReplyAttachment(null) clearAttachment(replyAttachment, setReplyAttachment)
}} }}
className={`w-full text-left p-4 rounded-xl border transition-all ${ className={`w-full text-left p-4 rounded-xl border transition-all ${
selectedTicket?.id === ticket.id selectedTicket?.id === ticket.id
@@ -555,14 +585,14 @@ export default function Support() {
className="hidden" className="hidden"
onChange={(e) => { onChange={(e) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (file) handleFileSelect(file, setCreateAttachment) if (file) handleFileSelect(file, setCreateAttachment, createAttachment)
e.target.value = '' e.target.value = ''
}} }}
/> />
{createAttachment ? ( {createAttachment ? (
<AttachmentPreview <AttachmentPreview
attachment={createAttachment} attachment={createAttachment}
onRemove={() => setCreateAttachment(null)} onRemove={() => clearAttachment(createAttachment, setCreateAttachment)}
/> />
) : ( ) : (
<button <button
@@ -604,7 +634,7 @@ export default function Support() {
type="button" type="button"
onClick={() => { onClick={() => {
setShowCreateForm(false) setShowCreateForm(false)
setCreateAttachment(null) clearAttachment(createAttachment, setCreateAttachment)
}} }}
className="btn-secondary" className="btn-secondary"
> >
@@ -704,14 +734,14 @@ export default function Support() {
className="hidden" className="hidden"
onChange={(e) => { onChange={(e) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (file) handleFileSelect(file, setReplyAttachment) if (file) handleFileSelect(file, setReplyAttachment, replyAttachment)
e.target.value = '' e.target.value = ''
}} }}
/> />
{replyAttachment ? ( {replyAttachment ? (
<AttachmentPreview <AttachmentPreview
attachment={replyAttachment} attachment={replyAttachment}
onRemove={() => setReplyAttachment(null)} onRemove={() => clearAttachment(replyAttachment, setReplyAttachment)}
/> />
) : ( ) : (
<button <button

View File

@@ -453,6 +453,7 @@ export interface LocalizedText {
} }
export interface AppButton { export interface AppButton {
id?: string // Unique identifier for React key (client-side only)
buttonLink: string buttonLink: string
buttonText: LocalizedText buttonText: LocalizedText
} }

3
src/vite-env.d.ts vendored
View File

@@ -1,8 +1,5 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
// Global constant defined in vite.config.ts
declare const __APP_VERSION__: string
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_API_URL: string readonly VITE_API_URL: string
readonly VITE_TELEGRAM_BOT_USERNAME?: string readonly VITE_TELEGRAM_BOT_USERNAME?: string

View File

@@ -1,26 +1,9 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { execSync } from 'child_process'
// Get version from Git tag or fallback to 'dev'
function getGitVersion(): string {
try {
// Try to get version from git tag
return execSync('git describe --tags --always', { encoding: 'utf-8' }).trim()
} catch {
return 'dev'
}
}
const appVersion = process.env.VITE_APP_VERSION || getGitVersion()
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
// Define global constants
define: {
__APP_VERSION__: JSON.stringify(appVersion),
},
// Base path - use '/' for standalone Docker deployment // Base path - use '/' for standalone Docker deployment
// Change to '/cabinet/' if serving from a sub-path // Change to '/cabinet/' if serving from a sub-path
base: '/', base: '/',