mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +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:
1
.github/workflows/docker.yml
vendored
1
.github/workflows/docker.yml
vendored
@@ -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 }}
|
|
||||||
|
|||||||
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@@ -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: |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
3
src/vite-env.d.ts
vendored
@@ -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
|
||||||
|
|||||||
@@ -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: '/',
|
||||||
|
|||||||
Reference in New Issue
Block a user