mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
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:
@@ -189,7 +189,13 @@ function AppEditorModal({ app, platform, isNew, onSave, onClose }: AppEditorModa
|
||||
const addButton = (stepKey: 'installationStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep') => {
|
||||
const step = editedApp[stepKey] || emptyAppStep()
|
||||
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) => {
|
||||
@@ -253,7 +259,7 @@ function AppEditorModal({ app, platform, isNew, onSave, onClose }: AppEditorModa
|
||||
</button>
|
||||
</div>
|
||||
{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">
|
||||
<span className="text-xs text-dark-400">{t('admin.apps.button')} #{index + 1}</span>
|
||||
<button
|
||||
|
||||
@@ -190,14 +190,14 @@ function RevenueChart({ data }: { data: { date: string; amount_rubles: number }[
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{last7Days.map((item, index) => {
|
||||
{last7Days.map((item) => {
|
||||
const percentage = (item.amount_rubles / maxValue) * 100
|
||||
const date = new Date(item.date)
|
||||
const dayName = date.toLocaleDateString('ru-RU', { weekday: 'short' })
|
||||
const dayNum = date.getDate()
|
||||
|
||||
return (
|
||||
<div key={index} className="group">
|
||||
<div key={item.date} className="group">
|
||||
<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 font-semibold text-dark-100">{formatAmount(item.amount_rubles)} {currencySymbol}</span>
|
||||
|
||||
@@ -154,162 +154,203 @@ function DesktopAdminCard({ to, icon, title, description, bgColor, textColor }:
|
||||
)
|
||||
}
|
||||
|
||||
// Section group definition
|
||||
interface SectionGroup {
|
||||
title: string
|
||||
emoji: string
|
||||
sections: AdminSection[]
|
||||
}
|
||||
|
||||
export default function AdminPanel() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const adminSections: AdminSection[] = [
|
||||
// Grouped admin sections
|
||||
const sectionGroups: SectionGroup[] = [
|
||||
{
|
||||
to: '/admin/dashboard',
|
||||
icon: <ChartIcon />,
|
||||
mobileIcon: <ChartIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.dashboard'),
|
||||
description: t('admin.panel.dashboardDesc'),
|
||||
color: 'success',
|
||||
bgColor: 'bg-emerald-500/20',
|
||||
textColor: 'text-emerald-400'
|
||||
title: 'Аналитика',
|
||||
emoji: '📊',
|
||||
sections: [
|
||||
{
|
||||
to: '/admin/dashboard',
|
||||
icon: <ChartIcon />,
|
||||
mobileIcon: <ChartIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.dashboard'),
|
||||
description: t('admin.panel.dashboardDesc'),
|
||||
color: 'success',
|
||||
bgColor: 'bg-emerald-500/20',
|
||||
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'
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
to: '/admin/tickets',
|
||||
icon: <TicketIcon />,
|
||||
mobileIcon: <TicketIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.tickets'),
|
||||
description: t('admin.panel.ticketsDesc'),
|
||||
color: 'warning',
|
||||
bgColor: 'bg-amber-500/20',
|
||||
textColor: 'text-amber-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',
|
||||
icon: <TicketIcon />,
|
||||
mobileIcon: <TicketIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.tickets'),
|
||||
description: t('admin.panel.ticketsDesc'),
|
||||
color: 'warning',
|
||||
bgColor: 'bg-amber-500/20',
|
||||
textColor: 'text-amber-400'
|
||||
},
|
||||
{
|
||||
to: '/admin/ban-system',
|
||||
icon: <BanSystemIcon />,
|
||||
mobileIcon: <BanSystemIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.banSystem'),
|
||||
description: t('admin.panel.banSystemDesc'),
|
||||
color: 'error',
|
||||
bgColor: 'bg-red-500/20',
|
||||
textColor: 'text-red-400'
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
to: '/admin/settings',
|
||||
icon: <CogIcon />,
|
||||
mobileIcon: <CogIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.settings'),
|
||||
description: t('admin.panel.settingsDesc'),
|
||||
color: 'accent',
|
||||
bgColor: 'bg-blue-500/20',
|
||||
textColor: 'text-blue-400'
|
||||
title: 'Тарифы и продажи',
|
||||
emoji: '💰',
|
||||
sections: [
|
||||
{
|
||||
to: '/admin/tariffs',
|
||||
icon: <TariffIcon />,
|
||||
mobileIcon: <TariffIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.tariffs'),
|
||||
description: t('admin.panel.tariffsDesc'),
|
||||
color: 'info',
|
||||
bgColor: 'bg-cyan-500/20',
|
||||
textColor: 'text-cyan-400'
|
||||
},
|
||||
{
|
||||
to: '/admin/promocodes',
|
||||
icon: <PromocodeIcon />,
|
||||
mobileIcon: <PromocodeIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.promocodes', 'Промокоды'),
|
||||
description: t('admin.panel.promocodesDesc', 'Управление промокодами'),
|
||||
color: 'violet',
|
||||
bgColor: 'bg-violet-500/20',
|
||||
textColor: 'text-violet-400'
|
||||
},
|
||||
{
|
||||
to: '/admin/promo-offers',
|
||||
icon: <PromoOffersIcon />,
|
||||
mobileIcon: <PromoOffersIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.promoOffers', 'Промопредложения'),
|
||||
description: t('admin.panel.promoOffersDesc', 'Персональные скидки и предложения'),
|
||||
color: 'orange',
|
||||
bgColor: 'bg-orange-500/20',
|
||||
textColor: 'text-orange-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'
|
||||
title: 'Маркетинг',
|
||||
emoji: '📣',
|
||||
sections: [
|
||||
{
|
||||
to: '/admin/campaigns',
|
||||
icon: <CampaignIcon />,
|
||||
mobileIcon: <CampaignIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.campaigns', 'Кампании'),
|
||||
description: t('admin.panel.campaignsDesc', 'Рекламные кампании'),
|
||||
color: 'orange',
|
||||
bgColor: 'bg-orange-500/20',
|
||||
textColor: 'text-orange-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/wheel',
|
||||
icon: <WheelIcon />,
|
||||
mobileIcon: <WheelIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.wheel'),
|
||||
description: t('admin.panel.wheelDesc'),
|
||||
color: 'error',
|
||||
bgColor: 'bg-rose-500/20',
|
||||
textColor: 'text-rose-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',
|
||||
bgColor: 'bg-rose-500/20',
|
||||
textColor: 'text-rose-400'
|
||||
},
|
||||
{
|
||||
to: '/admin/tariffs',
|
||||
icon: <TariffIcon />,
|
||||
mobileIcon: <TariffIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.tariffs'),
|
||||
description: t('admin.panel.tariffsDesc'),
|
||||
color: 'info',
|
||||
bgColor: 'bg-cyan-500/20',
|
||||
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',
|
||||
icon: <PromocodeIcon />,
|
||||
mobileIcon: <PromocodeIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.promocodes', 'Промокоды'),
|
||||
description: t('admin.panel.promocodesDesc', 'Управление промокодами'),
|
||||
color: 'violet',
|
||||
bgColor: 'bg-violet-500/20',
|
||||
textColor: 'text-violet-400'
|
||||
},
|
||||
{
|
||||
to: '/admin/promo-offers',
|
||||
icon: <PromoOffersIcon />,
|
||||
mobileIcon: <PromoOffersIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.promoOffers', 'Промопредложения'),
|
||||
description: t('admin.panel.promoOffersDesc', 'Персональные скидки и предложения'),
|
||||
color: 'orange',
|
||||
bgColor: 'bg-orange-500/20',
|
||||
textColor: 'text-orange-400'
|
||||
},
|
||||
{
|
||||
to: '/admin/campaigns',
|
||||
icon: <CampaignIcon />,
|
||||
mobileIcon: <CampaignIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.campaigns', 'Кампании'),
|
||||
description: t('admin.panel.campaignsDesc', 'Рекламные кампании'),
|
||||
color: 'orange',
|
||||
bgColor: 'bg-orange-500/20',
|
||||
textColor: 'text-orange-400'
|
||||
},
|
||||
{
|
||||
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/ban-system',
|
||||
icon: <BanSystemIcon />,
|
||||
mobileIcon: <BanSystemIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.banSystem'),
|
||||
description: t('admin.panel.banSystemDesc'),
|
||||
color: 'error',
|
||||
bgColor: 'bg-red-500/20',
|
||||
textColor: 'text-red-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'
|
||||
},
|
||||
{
|
||||
to: '/admin/remnawave',
|
||||
icon: <RemnawaveIcon />,
|
||||
mobileIcon: <RemnawaveIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.remnawave', 'RemnaWave'),
|
||||
description: t('admin.panel.remnawaveDesc', 'Управление панелью и статистика'),
|
||||
color: 'purple',
|
||||
bgColor: 'bg-purple-500/20',
|
||||
textColor: 'text-purple-400'
|
||||
title: 'Система',
|
||||
emoji: '⚙️',
|
||||
sections: [
|
||||
{
|
||||
to: '/admin/settings',
|
||||
icon: <CogIcon />,
|
||||
mobileIcon: <CogIcon className="w-6 h-6" />,
|
||||
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',
|
||||
icon: <RemnawaveIcon />,
|
||||
mobileIcon: <RemnawaveIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.remnawave', 'RemnaWave'),
|
||||
description: t('admin.panel.remnawaveDesc', 'Управление панелью и статистика'),
|
||||
color: 'purple',
|
||||
bgColor: 'bg-purple-500/20',
|
||||
textColor: 'text-purple-400'
|
||||
},
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
// Flatten all sections for mobile view
|
||||
const allSections = sectionGroups.flatMap(group => group.sections)
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{/* Header - compact on mobile */}
|
||||
@@ -323,17 +364,30 @@ export default function AdminPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: Compact 2-column grid */}
|
||||
{/* Mobile: Compact grid (all sections) */}
|
||||
<div className="grid grid-cols-3 gap-3 sm:hidden">
|
||||
{adminSections.map((section) => (
|
||||
{allSections.map((section) => (
|
||||
<MobileAdminCard key={section.to} {...section} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tablet/Desktop: List style */}
|
||||
<div className="hidden sm:grid sm:grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{adminSections.map((section) => (
|
||||
<DesktopAdminCard key={section.to} {...section} />
|
||||
{/* Tablet/Desktop: Grouped sections */}
|
||||
<div className="hidden sm:block space-y-6">
|
||||
{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} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { adminWheelApi, type WheelPrizeAdmin } from '../api/wheel'
|
||||
import { adminWheelApi, type WheelPrizeAdmin, type CreateWheelPrizeData } from '../api/wheel'
|
||||
|
||||
// Icons
|
||||
const BackIcon = () => (
|
||||
@@ -468,7 +468,7 @@ export default function AdminWheel() {
|
||||
if (editingPrize) {
|
||||
updatePrizeMutation.mutate({ id: editingPrize.id, data })
|
||||
} else {
|
||||
createPrizeMutation.mutate(data as any)
|
||||
createPrizeMutation.mutate(data as CreateWheelPrizeData)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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 { useTranslation } from 'react-i18next'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
@@ -46,6 +46,8 @@ export default function DeepLinkRedirect() {
|
||||
const [status, setStatus] = useState<Status>('countdown')
|
||||
const [countdown, setCountdown] = useState(COUNTDOWN_SECONDS)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const fallbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Get branding
|
||||
const { data: branding } = useQuery({
|
||||
@@ -135,23 +137,34 @@ export default function DeepLinkRedirect() {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer)
|
||||
openDeepLink()
|
||||
// Show fallback after a delay
|
||||
setTimeout(() => setStatus('fallback'), 2000)
|
||||
// Show fallback after a delay - store ref for cleanup
|
||||
fallbackTimeoutRef.current = setTimeout(() => setStatus('fallback'), 2000)
|
||||
return 0
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
return () => {
|
||||
clearInterval(timer)
|
||||
// Cleanup fallback timeout on unmount
|
||||
if (fallbackTimeoutRef.current) {
|
||||
clearTimeout(fallbackTimeoutRef.current)
|
||||
fallbackTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
}, [deepLink, status, openDeepLink])
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
const linkToCopy = subscriptionUrl || deepLink
|
||||
// Clear previous timeout to prevent stacking
|
||||
if (copiedTimeoutRef.current) {
|
||||
clearTimeout(copiedTimeoutRef.current)
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(linkToCopy)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
copiedTimeoutRef.current = setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = linkToCopy
|
||||
@@ -160,10 +173,19 @@ export default function DeepLinkRedirect() {
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
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
|
||||
const progress = ((COUNTDOWN_SECONDS - countdown) / COUNTDOWN_SECONDS) * 100
|
||||
|
||||
|
||||
@@ -117,7 +117,9 @@ export default function Login() {
|
||||
await loginWithTelegram(tg.initData)
|
||||
navigate(getReturnUrl(), { replace: true })
|
||||
} 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'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -137,8 +139,16 @@ export default function Login() {
|
||||
await loginWithEmail(email, password)
|
||||
navigate(getReturnUrl(), { replace: true })
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
setError(error.response?.data?.detail || t('common.error'))
|
||||
const error = err as { response?: { status?: number; data?: { detail?: string } } }
|
||||
// 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 {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
@@ -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 { useTranslation } from 'react-i18next'
|
||||
import { ticketsApi } from '../api/tickets'
|
||||
@@ -91,6 +91,7 @@ function MessageMedia({ message, t }: { message: TicketMessage; t: (key: string)
|
||||
<button
|
||||
className="absolute top-4 right-4 text-white/70 hover:text-white"
|
||||
onClick={() => setShowFullImage(false)}
|
||||
aria-label="Close fullscreen"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
@@ -141,6 +142,29 @@ export default function Support() {
|
||||
const createFileInputRef = 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
|
||||
const { data: supportConfig, isLoading: configLoading } = useQuery({
|
||||
queryKey: ['support-config'],
|
||||
@@ -162,8 +186,14 @@ export default function Support() {
|
||||
// Handle file selection
|
||||
const handleFileSelect = async (
|
||||
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
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
@@ -224,7 +254,7 @@ export default function Support() {
|
||||
setShowCreateForm(false)
|
||||
setNewTitle('')
|
||||
setNewMessage('')
|
||||
setCreateAttachment(null)
|
||||
clearAttachment(createAttachment, setCreateAttachment)
|
||||
setSelectedTicket(ticket)
|
||||
},
|
||||
})
|
||||
@@ -242,7 +272,7 @@ export default function Support() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['ticket', selectedTicket?.id] })
|
||||
setReplyMessage('')
|
||||
setReplyAttachment(null)
|
||||
clearAttachment(replyAttachment, setReplyAttachment)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -443,7 +473,7 @@ export default function Support() {
|
||||
onClick={() => {
|
||||
setShowCreateForm(true)
|
||||
setSelectedTicket(null)
|
||||
setCreateAttachment(null)
|
||||
clearAttachment(createAttachment, setCreateAttachment)
|
||||
}}
|
||||
className="btn-primary"
|
||||
>
|
||||
@@ -469,7 +499,7 @@ export default function Support() {
|
||||
onClick={() => {
|
||||
setSelectedTicket(ticket as unknown as TicketDetail)
|
||||
setShowCreateForm(false)
|
||||
setReplyAttachment(null)
|
||||
clearAttachment(replyAttachment, setReplyAttachment)
|
||||
}}
|
||||
className={`w-full text-left p-4 rounded-xl border transition-all ${
|
||||
selectedTicket?.id === ticket.id
|
||||
@@ -555,14 +585,14 @@ export default function Support() {
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleFileSelect(file, setCreateAttachment)
|
||||
if (file) handleFileSelect(file, setCreateAttachment, createAttachment)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
{createAttachment ? (
|
||||
<AttachmentPreview
|
||||
attachment={createAttachment}
|
||||
onRemove={() => setCreateAttachment(null)}
|
||||
onRemove={() => clearAttachment(createAttachment, setCreateAttachment)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
@@ -604,7 +634,7 @@ export default function Support() {
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCreateForm(false)
|
||||
setCreateAttachment(null)
|
||||
clearAttachment(createAttachment, setCreateAttachment)
|
||||
}}
|
||||
className="btn-secondary"
|
||||
>
|
||||
@@ -704,14 +734,14 @@ export default function Support() {
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleFileSelect(file, setReplyAttachment)
|
||||
if (file) handleFileSelect(file, setReplyAttachment, replyAttachment)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
{replyAttachment ? (
|
||||
<AttachmentPreview
|
||||
attachment={replyAttachment}
|
||||
onRemove={() => setReplyAttachment(null)}
|
||||
onRemove={() => clearAttachment(replyAttachment, setReplyAttachment)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user