Add files via upload

This commit is contained in:
Egor
2026-01-15 19:18:17 +03:00
committed by GitHub
parent b118008cdc
commit 7be6b5c0ae
70 changed files with 21835 additions and 0 deletions

629
src/pages/AdminApps.tsx Normal file
View File

@@ -0,0 +1,629 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { adminAppsApi, AppDefinition, LocalizedText, AppStep, AppButton } from '../api/adminApps'
// Icons
const AppsIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
</svg>
)
const PlusIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
)
const EditIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
)
const TrashIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
)
const ChevronUpIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
</svg>
)
const ChevronDownIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
)
const CopyIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75" />
</svg>
)
const StarIcon = ({ filled }: { filled: boolean }) => (
<svg className={`w-4 h-4 ${filled ? 'fill-yellow-400 text-yellow-400' : 'text-dark-500'}`} viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
</svg>
)
const PLATFORM_LABELS: Record<string, string> = {
ios: 'iOS',
android: 'Android',
macos: 'macOS',
windows: 'Windows',
linux: 'Linux',
androidTV: 'Android TV',
appleTV: 'Apple TV',
}
const PLATFORMS = ['ios', 'android', 'macos', 'windows', 'linux', 'androidTV', 'appleTV']
// Helper to create empty localized text
const emptyLocalizedText = (): LocalizedText => ({
en: '',
ru: '',
zh: '',
fa: '',
})
// Helper to create empty app step
const emptyAppStep = (): AppStep => ({
description: emptyLocalizedText(),
buttons: [],
})
// Helper to create empty app
const createEmptyApp = (platform: string): AppDefinition => ({
id: `new-app-${platform}-${Date.now()}`,
name: '',
isFeatured: false,
urlScheme: '',
installationStep: emptyAppStep(),
addSubscriptionStep: emptyAppStep(),
connectAndUseStep: emptyAppStep(),
})
interface AppEditorModalProps {
app: AppDefinition
platform: string
isNew: boolean
onSave: (app: AppDefinition) => void
onClose: () => void
}
function AppEditorModal({ app, platform, isNew, onSave, onClose }: AppEditorModalProps) {
const { t } = useTranslation()
const [editedApp, setEditedApp] = useState<AppDefinition>({ ...app })
const [activeTab, setActiveTab] = useState<'basic' | 'installation' | 'subscription' | 'connect' | 'additional'>('basic')
const updateField = <K extends keyof AppDefinition>(field: K, value: AppDefinition[K]) => {
setEditedApp((prev) => ({ ...prev, [field]: value }))
}
const updateLocalizedText = (
stepKey: 'installationStep' | 'addSubscriptionStep' | 'connectAndUseStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep',
field: 'description' | 'title',
lang: keyof LocalizedText,
value: string
) => {
setEditedApp((prev) => {
const step = prev[stepKey] || emptyAppStep()
const fieldValue = step[field] || emptyLocalizedText()
return {
...prev,
[stepKey]: {
...step,
[field]: { ...fieldValue, [lang]: value },
},
}
})
}
const updateButtons = (
stepKey: 'installationStep' | 'addSubscriptionStep' | 'connectAndUseStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep',
buttons: AppButton[]
) => {
setEditedApp((prev) => {
const step = prev[stepKey] || emptyAppStep()
return {
...prev,
[stepKey]: { ...step, buttons },
}
})
}
const addButton = (stepKey: 'installationStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep') => {
const step = editedApp[stepKey] || emptyAppStep()
const buttons = step.buttons || []
updateButtons(stepKey, [...buttons, { buttonLink: '', buttonText: emptyLocalizedText() }])
}
const removeButton = (stepKey: 'installationStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep', index: number) => {
const step = editedApp[stepKey] || emptyAppStep()
const buttons = step.buttons || []
updateButtons(stepKey, buttons.filter((_, i) => i !== index))
}
const updateButton = (
stepKey: 'installationStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep',
index: number,
field: 'buttonLink' | 'buttonText',
value: string | LocalizedText
) => {
const step = editedApp[stepKey] || emptyAppStep()
const buttons = [...(step.buttons || [])]
buttons[index] = { ...buttons[index], [field]: value }
updateButtons(stepKey, buttons)
}
const renderLocalizedTextInputs = (
stepKey: 'installationStep' | 'addSubscriptionStep' | 'connectAndUseStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep',
field: 'description' | 'title',
label: string
) => {
const step = editedApp[stepKey]
const value = step?.[field] || emptyLocalizedText()
return (
<div className="space-y-2">
<label className="block text-sm font-medium text-dark-300">{label}</label>
{(['en', 'ru'] as const).map((lang) => (
<div key={lang} className="flex gap-2 items-center">
<span className="w-8 text-xs text-dark-500 uppercase">{lang}</span>
<textarea
value={value[lang] || ''}
onChange={(e) => updateLocalizedText(stepKey, field, lang, e.target.value)}
className="input flex-1 text-sm"
rows={2}
/>
</div>
))}
</div>
)
}
const renderButtonsEditor = (stepKey: 'installationStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep') => {
const step = editedApp[stepKey]
const buttons = step?.buttons || []
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-dark-300">{t('admin.apps.buttons')}</label>
<button
type="button"
onClick={() => addButton(stepKey)}
className="text-xs text-accent-400 hover:text-accent-300"
>
+ {t('admin.apps.addButton')}
</button>
</div>
{buttons.map((button, index) => (
<div key={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
type="button"
onClick={() => removeButton(stepKey, index)}
className="text-error-400 hover:text-error-300"
>
<TrashIcon />
</button>
</div>
<input
type="url"
value={button.buttonLink}
onChange={(e) => updateButton(stepKey, index, 'buttonLink', e.target.value)}
placeholder="https://..."
className="input w-full text-sm"
/>
{(['en', 'ru'] as const).map((lang) => (
<div key={lang} className="flex gap-2 items-center">
<span className="w-8 text-xs text-dark-500 uppercase">{lang}</span>
<input
type="text"
value={button.buttonText[lang] || ''}
onChange={(e) => {
const newButtonText = { ...button.buttonText, [lang]: e.target.value }
updateButton(stepKey, index, 'buttonText', newButtonText)
}}
placeholder={t('admin.apps.buttonText')}
className="input flex-1 text-sm"
/>
</div>
))}
</div>
))}
</div>
)
}
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-dark-900 rounded-xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="p-4 border-b border-dark-700 flex items-center justify-between">
<h2 className="text-lg font-semibold text-dark-100">
{isNew ? t('admin.apps.createApp') : t('admin.apps.editApp')} - {PLATFORM_LABELS[platform]}
</h2>
<button onClick={onClose} className="text-dark-400 hover:text-dark-200">
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-dark-700 overflow-x-auto">
{(['basic', 'installation', 'subscription', 'connect', 'additional'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 text-sm font-medium whitespace-nowrap ${
activeTab === tab
? 'text-accent-400 border-b-2 border-accent-400'
: 'text-dark-400 hover:text-dark-200'
}`}
>
{t(`admin.apps.tabs.${tab}`)}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{activeTab === 'basic' && (
<>
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">{t('admin.apps.appId')}</label>
<input
type="text"
value={editedApp.id}
onChange={(e) => updateField('id', e.target.value)}
className="input w-full"
disabled={!isNew}
/>
</div>
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">{t('admin.apps.appName')}</label>
<input
type="text"
value={editedApp.name}
onChange={(e) => updateField('name', e.target.value)}
className="input w-full"
/>
</div>
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">{t('admin.apps.urlScheme')}</label>
<input
type="text"
value={editedApp.urlScheme}
onChange={(e) => updateField('urlScheme', e.target.value)}
className="input w-full"
placeholder="app://add/"
/>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={editedApp.isFeatured}
onChange={(e) => updateField('isFeatured', e.target.checked)}
className="w-4 h-4 accent-accent-500"
/>
<span className="text-sm text-dark-300">{t('admin.apps.featured')}</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={editedApp.isNeedBase64Encoding || false}
onChange={(e) => updateField('isNeedBase64Encoding', e.target.checked || undefined)}
className="w-4 h-4 accent-accent-500"
/>
<span className="text-sm text-dark-300">{t('admin.apps.base64Encoding')}</span>
</label>
</div>
</>
)}
{activeTab === 'installation' && (
<>
{renderLocalizedTextInputs('installationStep', 'description', t('admin.apps.installDescription'))}
{renderButtonsEditor('installationStep')}
</>
)}
{activeTab === 'subscription' && (
<>
{renderLocalizedTextInputs('addSubscriptionStep', 'description', t('admin.apps.subscriptionDescription'))}
</>
)}
{activeTab === 'connect' && (
<>
{renderLocalizedTextInputs('connectAndUseStep', 'description', t('admin.apps.connectDescription'))}
</>
)}
{activeTab === 'additional' && (
<>
<div className="space-y-4">
<h3 className="text-sm font-semibold text-dark-200">{t('admin.apps.beforeSubscription')}</h3>
{renderLocalizedTextInputs('additionalBeforeAddSubscriptionStep', 'title', t('admin.apps.stepTitle'))}
{renderLocalizedTextInputs('additionalBeforeAddSubscriptionStep', 'description', t('admin.apps.stepDescription'))}
{renderButtonsEditor('additionalBeforeAddSubscriptionStep')}
</div>
<div className="border-t border-dark-700 pt-4 space-y-4">
<h3 className="text-sm font-semibold text-dark-200">{t('admin.apps.afterSubscription')}</h3>
{renderLocalizedTextInputs('additionalAfterAddSubscriptionStep', 'title', t('admin.apps.stepTitle'))}
{renderLocalizedTextInputs('additionalAfterAddSubscriptionStep', 'description', t('admin.apps.stepDescription'))}
{renderButtonsEditor('additionalAfterAddSubscriptionStep')}
</div>
</>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-dark-700 flex justify-end gap-3">
<button onClick={onClose} className="btn-secondary">
{t('common.cancel')}
</button>
<button onClick={() => onSave(editedApp)} className="btn-primary">
{t('common.save')}
</button>
</div>
</div>
</div>
)
}
export default function AdminApps() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [selectedPlatform, setSelectedPlatform] = useState<string>('ios')
const [editingApp, setEditingApp] = useState<{ app: AppDefinition; isNew: boolean } | null>(null)
const [copyTarget, setCopyTarget] = useState<{ appId: string; platform: string } | null>(null)
const { data: apps, isLoading } = useQuery({
queryKey: ['admin-apps', selectedPlatform],
queryFn: () => adminAppsApi.getPlatformApps(selectedPlatform),
})
const createMutation = useMutation({
mutationFn: ({ platform, app }: { platform: string; app: AppDefinition }) =>
adminAppsApi.createApp(platform, app),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-apps'] })
setEditingApp(null)
},
})
const updateMutation = useMutation({
mutationFn: ({ platform, appId, app }: { platform: string; appId: string; app: AppDefinition }) =>
adminAppsApi.updateApp(platform, appId, app),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-apps'] })
setEditingApp(null)
},
})
const deleteMutation = useMutation({
mutationFn: ({ platform, appId }: { platform: string; appId: string }) =>
adminAppsApi.deleteApp(platform, appId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-apps'] })
},
})
const reorderMutation = useMutation({
mutationFn: ({ platform, appIds }: { platform: string; appIds: string[] }) =>
adminAppsApi.reorderApps(platform, appIds),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-apps'] })
},
})
const copyMutation = useMutation({
mutationFn: ({ platform, appId, targetPlatform }: { platform: string; appId: string; targetPlatform: string }) =>
adminAppsApi.copyApp(platform, appId, targetPlatform),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-apps'] })
setCopyTarget(null)
},
})
const handleMoveUp = (index: number) => {
if (!apps || index === 0) return
const newOrder = [...apps]
;[newOrder[index - 1], newOrder[index]] = [newOrder[index], newOrder[index - 1]]
reorderMutation.mutate({ platform: selectedPlatform, appIds: newOrder.map((a) => a.id) })
}
const handleMoveDown = (index: number) => {
if (!apps || index === apps.length - 1) return
const newOrder = [...apps]
;[newOrder[index], newOrder[index + 1]] = [newOrder[index + 1], newOrder[index]]
reorderMutation.mutate({ platform: selectedPlatform, appIds: newOrder.map((a) => a.id) })
}
const handleSave = (app: AppDefinition) => {
if (editingApp?.isNew) {
createMutation.mutate({ platform: selectedPlatform, app })
} else {
updateMutation.mutate({ platform: selectedPlatform, appId: app.id, app })
}
}
const handleDelete = (appId: string) => {
if (confirm(t('admin.apps.confirmDelete'))) {
deleteMutation.mutate({ platform: selectedPlatform, appId })
}
}
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<AppsIcon />
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('admin.apps.title')}</h1>
</div>
{/* Platform Tabs */}
<div className="flex flex-wrap gap-2">
{PLATFORMS.map((platform) => (
<button
key={platform}
onClick={() => setSelectedPlatform(platform)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedPlatform === platform
? 'bg-accent-500 text-white'
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
}`}
>
{PLATFORM_LABELS[platform]}
</button>
))}
</div>
{/* Add App Button */}
<div className="flex justify-end">
<button
onClick={() => setEditingApp({ app: createEmptyApp(selectedPlatform), isNew: true })}
className="btn-primary flex items-center gap-2"
>
<PlusIcon />
{t('admin.apps.addApp')}
</button>
</div>
{/* Apps List */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
) : apps && apps.length > 0 ? (
<div className="space-y-3">
{apps.map((app, index) => (
<div
key={app.id}
className="card flex items-center gap-4 p-4"
>
{/* Reorder buttons */}
<div className="flex flex-col gap-1">
<button
onClick={() => handleMoveUp(index)}
disabled={index === 0}
className="p-1 text-dark-400 hover:text-dark-200 disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronUpIcon />
</button>
<button
onClick={() => handleMoveDown(index)}
disabled={index === apps.length - 1}
className="p-1 text-dark-400 hover:text-dark-200 disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronDownIcon />
</button>
</div>
{/* App Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-dark-100">{app.name}</span>
{app.isFeatured && <StarIcon filled />}
{app.isNeedBase64Encoding && (
<span className="text-xs bg-dark-700 text-dark-400 px-2 py-0.5 rounded">Base64</span>
)}
</div>
<div className="text-xs text-dark-500 font-mono truncate">{app.id}</div>
<div className="text-xs text-dark-400 mt-1">{app.urlScheme}</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<button
onClick={() => setCopyTarget({ appId: app.id, platform: selectedPlatform })}
className="p-2 text-dark-400 hover:text-dark-200 hover:bg-dark-700 rounded"
title={t('admin.apps.copyTo')}
>
<CopyIcon />
</button>
<button
onClick={() => setEditingApp({ app, isNew: false })}
className="p-2 text-dark-400 hover:text-dark-200 hover:bg-dark-700 rounded"
title={t('common.edit')}
>
<EditIcon />
</button>
<button
onClick={() => handleDelete(app.id)}
className="p-2 text-error-400 hover:text-error-300 hover:bg-error-500/10 rounded"
title={t('common.delete')}
>
<TrashIcon />
</button>
</div>
</div>
))}
</div>
) : (
<div className="card text-center py-12">
<AppsIcon />
<p className="text-dark-400 mt-4">{t('admin.apps.noApps')}</p>
<button
onClick={() => setEditingApp({ app: createEmptyApp(selectedPlatform), isNew: true })}
className="btn-primary mt-4"
>
{t('admin.apps.addFirstApp')}
</button>
</div>
)}
{/* Edit Modal */}
{editingApp && (
<AppEditorModal
app={editingApp.app}
platform={selectedPlatform}
isNew={editingApp.isNew}
onSave={handleSave}
onClose={() => setEditingApp(null)}
/>
)}
{/* Copy Modal */}
{copyTarget && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-dark-900 rounded-xl p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('admin.apps.copyTo')}</h3>
<div className="grid grid-cols-2 gap-2">
{PLATFORMS.filter((p) => p !== copyTarget.platform).map((platform) => (
<button
key={platform}
onClick={() => copyMutation.mutate({
platform: copyTarget.platform,
appId: copyTarget.appId,
targetPlatform: platform,
})}
className="px-4 py-2 bg-dark-800 hover:bg-dark-700 rounded-lg text-dark-200 text-sm"
>
{PLATFORM_LABELS[platform]}
</button>
))}
</div>
<button
onClick={() => setCopyTarget(null)}
className="btn-secondary w-full mt-4"
>
{t('common.cancel')}
</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,502 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { statsApi, type DashboardStats, type NodeStatus } from '../api/admin'
import { useCurrency } from '../hooks/useCurrency'
// Icons
const ChartIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
)
const ServerIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
</svg>
)
const UsersIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
)
const CurrencyIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
</svg>
)
const SubscriptionIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
</svg>
)
const RefreshIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
)
const PowerIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.636 5.636a9 9 0 1012.728 0M12 3v9" />
</svg>
)
const RestartIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
)
interface StatCardProps {
title: string
value: string | number
subtitle?: string
icon: React.ReactNode
color: 'accent' | 'success' | 'warning' | 'error' | 'info'
trend?: {
value: number
label: string
}
}
function StatCard({ title, value, subtitle, icon, color, trend }: StatCardProps) {
const colorClasses = {
accent: 'bg-accent-500/20 text-accent-400',
success: 'bg-success-500/20 text-success-400',
warning: 'bg-warning-500/20 text-warning-400',
error: 'bg-error-500/20 text-error-400',
info: 'bg-info-500/20 text-info-400',
}
return (
<div className="bg-dark-800/50 backdrop-blur rounded-xl border border-dark-700 p-5 hover:border-dark-600 transition-colors">
<div className="flex items-start justify-between mb-3">
<div className={`p-2.5 rounded-lg ${colorClasses[color]}`}>
{icon}
</div>
{trend && (
<div className={`text-xs px-2 py-1 rounded-full ${trend.value >= 0 ? 'bg-success-500/20 text-success-400' : 'bg-error-500/20 text-error-400'}`}>
{trend.value >= 0 ? '+' : ''}{trend.value}% {trend.label}
</div>
)}
</div>
<div className="text-2xl font-bold text-dark-100 mb-1">{value}</div>
<div className="text-sm text-dark-400">{title}</div>
{subtitle && <div className="text-xs text-dark-500 mt-1">{subtitle}</div>}
</div>
)
}
interface NodeCardProps {
node: NodeStatus
onRestart: (uuid: string) => void
onToggle: (uuid: string) => void
isLoading: boolean
}
function NodeCard({ node, onRestart, onToggle, isLoading }: NodeCardProps) {
const { t } = useTranslation()
const getStatusColor = () => {
if (node.is_disabled) return 'bg-dark-600 text-dark-400'
if (node.is_connected) return 'bg-success-500/20 text-success-400'
return 'bg-error-500/20 text-error-400'
}
const getStatusText = () => {
if (node.is_disabled) return t('adminDashboard.nodes.disabled')
if (node.is_connected) return t('adminDashboard.nodes.online')
return t('adminDashboard.nodes.offline')
}
const formatTraffic = (bytes?: number) => {
if (!bytes) return '-'
const gb = bytes / (1024 * 1024 * 1024)
if (gb >= 1000) return `${(gb / 1000).toFixed(1)} TB`
return `${gb.toFixed(1)} GB`
}
return (
<div className={`bg-dark-800/50 backdrop-blur rounded-xl border ${node.is_disabled ? 'border-dark-700' : node.is_connected ? 'border-success-500/30' : 'border-error-500/30'} p-4 hover:border-dark-600 transition-colors`}>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${node.is_disabled ? 'bg-dark-500' : node.is_connected ? 'bg-success-500 animate-pulse' : 'bg-error-500'}`} />
<div>
<div className="font-medium text-dark-100">{node.name}</div>
<div className="text-xs text-dark-500">{node.address}</div>
</div>
</div>
<span className={`text-xs px-2 py-1 rounded-full ${getStatusColor()}`}>
{getStatusText()}
</span>
</div>
<div className="grid grid-cols-2 gap-3 mb-3">
<div className="bg-dark-900/50 rounded-lg p-2.5">
<div className="text-xs text-dark-500 mb-0.5">{t('adminDashboard.nodes.usersOnline')}</div>
<div className="text-lg font-semibold text-dark-100">{node.users_online}</div>
</div>
<div className="bg-dark-900/50 rounded-lg p-2.5">
<div className="text-xs text-dark-500 mb-0.5">{t('adminDashboard.nodes.traffic')}</div>
<div className="text-lg font-semibold text-dark-100">{formatTraffic(node.traffic_used_bytes)}</div>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => onToggle(node.uuid)}
disabled={isLoading}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-sm font-medium transition-colors ${
node.is_disabled
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
: 'bg-warning-500/20 text-warning-400 hover:bg-warning-500/30'
} disabled:opacity-50`}
>
<PowerIcon />
{node.is_disabled ? t('adminDashboard.nodes.enable') : t('adminDashboard.nodes.disable')}
</button>
<button
onClick={() => onRestart(node.uuid)}
disabled={isLoading || node.is_disabled}
className="flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-sm font-medium bg-accent-500/20 text-accent-400 hover:bg-accent-500/30 transition-colors disabled:opacity-50"
>
<RestartIcon />
</button>
</div>
</div>
)
}
function RevenueChart({ data }: { data: { date: string; amount_rubles: number }[] }) {
const { t } = useTranslation()
const { formatAmount, currencySymbol } = useCurrency()
if (!data || data.length === 0) {
return (
<div className="flex items-center justify-center h-48 text-dark-500">
{t('common.noData')}
</div>
)
}
const maxValue = Math.max(...data.map(d => d.amount_rubles), 1)
const last7Days = data.slice(-7)
return (
<div className="h-48">
<div className="flex items-end justify-between h-36 gap-2">
{last7Days.map((item, index) => {
const height = (item.amount_rubles / maxValue) * 100
const date = new Date(item.date)
const dayName = date.toLocaleDateString('ru-RU', { weekday: 'short' })
return (
<div key={index} className="flex-1 flex flex-col items-center gap-2">
<div
className="w-full bg-accent-500/80 rounded-t-lg hover:bg-accent-500 transition-colors cursor-pointer group relative"
style={{ height: `${Math.max(height, 4)}%` }}
>
<div className="absolute -top-8 left-1/2 -translate-x-1/2 bg-dark-800 px-2 py-1 rounded text-xs text-dark-100 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-10 border border-dark-600">
{formatAmount(item.amount_rubles)} {currencySymbol}
</div>
</div>
<div className="text-xs text-dark-500">{dayName}</div>
</div>
)
})}
</div>
</div>
)
}
export default function AdminDashboard() {
const { t } = useTranslation()
const { formatAmount, currencySymbol } = useCurrency()
const [stats, setStats] = useState<DashboardStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const fetchStats = async () => {
try {
setLoading(true)
setError(null)
const data = await statsApi.getDashboardStats()
setStats(data)
} catch (err) {
setError(t('adminDashboard.loadError'))
console.error('Failed to load dashboard stats:', err)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchStats()
// Refresh every 30 seconds
const interval = setInterval(fetchStats, 30000)
return () => clearInterval(interval)
}, [])
const handleRestartNode = async (uuid: string) => {
try {
setActionLoading(uuid)
await statsApi.restartNode(uuid)
// Refresh stats after action
setTimeout(fetchStats, 2000)
} catch (err) {
console.error('Failed to restart node:', err)
} finally {
setActionLoading(null)
}
}
const handleToggleNode = async (uuid: string) => {
try {
setActionLoading(uuid)
await statsApi.toggleNode(uuid)
await fetchStats()
} catch (err) {
console.error('Failed to toggle node:', err)
} finally {
setActionLoading(null)
}
}
if (loading && !stats) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (error && !stats) {
return (
<div className="flex flex-col items-center justify-center h-64 gap-4">
<div className="text-error-400">{error}</div>
<button onClick={fetchStats} className="btn-primary">
{t('common.loading')}
</button>
</div>
)
}
return (
<div className="animate-fade-in space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-3 bg-accent-500/20 rounded-xl">
<ChartIcon />
</div>
<div>
<h1 className="text-2xl font-bold text-dark-100">{t('adminDashboard.title')}</h1>
<p className="text-dark-400">{t('adminDashboard.subtitle')}</p>
</div>
</div>
<button
onClick={fetchStats}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 bg-dark-800 rounded-lg text-dark-300 hover:text-dark-100 hover:bg-dark-700 transition-colors disabled:opacity-50"
>
<RefreshIcon />
{t('adminDashboard.refresh')}
</button>
</div>
{/* Main Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
title={t('adminDashboard.stats.usersOnline')}
value={stats?.nodes.total_users_online || 0}
icon={<UsersIcon />}
color="success"
/>
<StatCard
title={t('adminDashboard.stats.activeSubscriptions')}
value={stats?.subscriptions.active || 0}
subtitle={`${t('adminDashboard.stats.total')}: ${stats?.subscriptions.total || 0}`}
icon={<SubscriptionIcon />}
color="accent"
/>
<StatCard
title={t('adminDashboard.stats.incomeToday')}
value={`${formatAmount(stats?.financial.income_today_rubles || 0)} ${currencySymbol}`}
icon={<CurrencyIcon />}
color="warning"
/>
<StatCard
title={t('adminDashboard.stats.incomeMonth')}
value={`${formatAmount(stats?.financial.income_month_rubles || 0)} ${currencySymbol}`}
icon={<CurrencyIcon />}
color="info"
/>
</div>
{/* Nodes Section */}
<div className="bg-dark-800/30 backdrop-blur rounded-xl border border-dark-700 p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<ServerIcon />
<div>
<h2 className="text-lg font-semibold text-dark-100">{t('adminDashboard.nodes.title')}</h2>
<p className="text-sm text-dark-400">
{stats?.nodes.online || 0} {t('adminDashboard.nodes.online').toLowerCase()} / {stats?.nodes.total || 0} {t('adminDashboard.stats.total').toLowerCase()}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="flex items-center gap-1.5 text-xs text-dark-400">
<span className="w-2 h-2 rounded-full bg-success-500"></span>
{stats?.nodes.online || 0}
</span>
<span className="flex items-center gap-1.5 text-xs text-dark-400">
<span className="w-2 h-2 rounded-full bg-error-500"></span>
{stats?.nodes.offline || 0}
</span>
<span className="flex items-center gap-1.5 text-xs text-dark-400">
<span className="w-2 h-2 rounded-full bg-dark-500"></span>
{stats?.nodes.disabled || 0}
</span>
</div>
</div>
{stats?.nodes.nodes && stats.nodes.nodes.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{stats.nodes.nodes.map((node) => (
<NodeCard
key={node.uuid}
node={node}
onRestart={handleRestartNode}
onToggle={handleToggleNode}
isLoading={actionLoading === node.uuid}
/>
))}
</div>
) : (
<div className="text-center py-8 text-dark-500">
{t('adminDashboard.nodes.noNodes')}
</div>
)}
</div>
{/* Revenue and Subscriptions */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Revenue Chart */}
<div className="bg-dark-800/30 backdrop-blur rounded-xl border border-dark-700 p-5">
<div className="flex items-center gap-3 mb-4">
<CurrencyIcon />
<div>
<h2 className="text-lg font-semibold text-dark-100">{t('adminDashboard.revenue.title')}</h2>
<p className="text-sm text-dark-400">{t('adminDashboard.revenue.last7Days')}</p>
</div>
</div>
<RevenueChart data={stats?.revenue_chart || []} />
<div className="grid grid-cols-2 gap-4 mt-4 pt-4 border-t border-dark-700">
<div>
<div className="text-xs text-dark-500 mb-1">{t('adminDashboard.stats.incomeTotal')}</div>
<div className="text-xl font-bold text-dark-100">{formatAmount(stats?.financial.income_total_rubles || 0)} {currencySymbol}</div>
</div>
<div>
<div className="text-xs text-dark-500 mb-1">{t('adminDashboard.stats.subscriptionIncome')}</div>
<div className="text-xl font-bold text-accent-400">{formatAmount(stats?.financial.subscription_income_rubles || 0)} {currencySymbol}</div>
</div>
</div>
</div>
{/* Subscription Stats */}
<div className="bg-dark-800/30 backdrop-blur rounded-xl border border-dark-700 p-5">
<div className="flex items-center gap-3 mb-4">
<SubscriptionIcon />
<div>
<h2 className="text-lg font-semibold text-dark-100">{t('adminDashboard.subscriptions.title')}</h2>
<p className="text-sm text-dark-400">{t('adminDashboard.subscriptions.subtitle')}</p>
</div>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="bg-dark-900/50 rounded-lg p-4">
<div className="text-xs text-dark-500 mb-1">{t('adminDashboard.subscriptions.active')}</div>
<div className="text-2xl font-bold text-success-400">{stats?.subscriptions.active || 0}</div>
</div>
<div className="bg-dark-900/50 rounded-lg p-4">
<div className="text-xs text-dark-500 mb-1">{t('adminDashboard.subscriptions.trial')}</div>
<div className="text-2xl font-bold text-warning-400">{stats?.subscriptions.trial || 0}</div>
</div>
<div className="bg-dark-900/50 rounded-lg p-4">
<div className="text-xs text-dark-500 mb-1">{t('adminDashboard.subscriptions.paid')}</div>
<div className="text-2xl font-bold text-accent-400">{stats?.subscriptions.paid || 0}</div>
</div>
<div className="bg-dark-900/50 rounded-lg p-4">
<div className="text-xs text-dark-500 mb-1">{t('adminDashboard.subscriptions.expired')}</div>
<div className="text-2xl font-bold text-error-400">{stats?.subscriptions.expired || 0}</div>
</div>
</div>
<div className="border-t border-dark-700 pt-4">
<div className="text-sm font-medium text-dark-300 mb-3">{t('adminDashboard.subscriptions.newSubscriptions')}</div>
<div className="grid grid-cols-3 gap-3">
<div className="text-center">
<div className="text-xl font-bold text-dark-100">{stats?.subscriptions.purchased_today || 0}</div>
<div className="text-xs text-dark-500">{t('adminDashboard.subscriptions.today')}</div>
</div>
<div className="text-center">
<div className="text-xl font-bold text-dark-100">{stats?.subscriptions.purchased_week || 0}</div>
<div className="text-xs text-dark-500">{t('adminDashboard.subscriptions.week')}</div>
</div>
<div className="text-center">
<div className="text-xl font-bold text-dark-100">{stats?.subscriptions.purchased_month || 0}</div>
<div className="text-xs text-dark-500">{t('adminDashboard.subscriptions.month')}</div>
</div>
</div>
</div>
{stats?.subscriptions.trial_to_paid_conversion !== undefined && (
<div className="bg-accent-500/10 rounded-lg p-4 border border-accent-500/20">
<div className="flex items-center justify-between">
<span className="text-sm text-dark-300">{t('adminDashboard.subscriptions.conversion')}</span>
<span className="text-lg font-bold text-accent-400">{stats.subscriptions.trial_to_paid_conversion.toFixed(1)}%</span>
</div>
</div>
)}
</div>
</div>
</div>
{/* Server Stats */}
{stats?.servers && (
<div className="bg-dark-800/30 backdrop-blur rounded-xl border border-dark-700 p-5">
<div className="flex items-center gap-3 mb-4">
<ServerIcon />
<h2 className="text-lg font-semibold text-dark-100">{t('adminDashboard.servers.title')}</h2>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-dark-900/50 rounded-lg p-4">
<div className="text-xs text-dark-500 mb-1">{t('adminDashboard.servers.total')}</div>
<div className="text-2xl font-bold text-dark-100">{stats.servers.total_servers}</div>
</div>
<div className="bg-dark-900/50 rounded-lg p-4">
<div className="text-xs text-dark-500 mb-1">{t('adminDashboard.servers.available')}</div>
<div className="text-2xl font-bold text-success-400">{stats.servers.available_servers}</div>
</div>
<div className="bg-dark-900/50 rounded-lg p-4">
<div className="text-xs text-dark-500 mb-1">{t('adminDashboard.servers.withConnections')}</div>
<div className="text-2xl font-bold text-accent-400">{stats.servers.servers_with_connections}</div>
</div>
<div className="bg-dark-900/50 rounded-lg p-4">
<div className="text-xs text-dark-500 mb-1">{t('adminDashboard.servers.revenue')}</div>
<div className="text-2xl font-bold text-warning-400">{formatAmount(stats.servers.total_revenue_rubles)} {currencySymbol}</div>
</div>
</div>
</div>
)}
</div>
)
}

155
src/pages/AdminPanel.tsx Normal file
View File

@@ -0,0 +1,155 @@
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
// Icons
const TicketIcon = () => (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" />
</svg>
)
const CogIcon = () => (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)
const PhoneIcon = () => (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
</svg>
)
const WheelIcon = () => (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
)
const TariffIcon = () => (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
</svg>
)
const ServerIcon = () => (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
</svg>
)
const AdminIcon = () => (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
)
const ChartIcon = () => (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
)
interface AdminCardProps {
to: string
icon: React.ReactNode
title: string
description: string
color: string
}
function AdminCard({ to, icon, title, description, color }: AdminCardProps) {
return (
<Link
to={to}
className={`block p-6 bg-dark-800 rounded-xl border border-dark-700 hover:border-${color}-500/50 hover:bg-dark-750 transition-all duration-200 group`}
>
<div className={`w-14 h-14 rounded-xl bg-${color}-500/20 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform`}>
<div className={`text-${color}-400`}>
{icon}
</div>
</div>
<h3 className="text-lg font-semibold text-dark-100 mb-1">{title}</h3>
<p className="text-sm text-dark-400">{description}</p>
</Link>
)
}
export default function AdminPanel() {
const { t } = useTranslation()
const adminSections = [
{
to: '/admin/dashboard',
icon: <ChartIcon />,
title: t('admin.nav.dashboard'),
description: t('admin.panel.dashboardDesc'),
color: 'success'
},
{
to: '/admin/tickets',
icon: <TicketIcon />,
title: t('admin.nav.tickets'),
description: t('admin.panel.ticketsDesc'),
color: 'warning'
},
{
to: '/admin/settings',
icon: <CogIcon />,
title: t('admin.nav.settings'),
description: t('admin.panel.settingsDesc'),
color: 'accent'
},
{
to: '/admin/apps',
icon: <PhoneIcon />,
title: t('admin.nav.apps'),
description: t('admin.panel.appsDesc'),
color: 'success'
},
{
to: '/admin/wheel',
icon: <WheelIcon />,
title: t('admin.nav.wheel'),
description: t('admin.panel.wheelDesc'),
color: 'error'
},
{
to: '/admin/tariffs',
icon: <TariffIcon />,
title: t('admin.nav.tariffs'),
description: t('admin.panel.tariffsDesc'),
color: 'info'
},
{
to: '/admin/servers',
icon: <ServerIcon />,
title: t('admin.nav.servers'),
description: t('admin.panel.serversDesc'),
color: 'purple'
},
]
return (
<div className="animate-fade-in">
{/* Header */}
<div className="flex items-center gap-3 mb-8">
<div className="p-3 bg-warning-500/20 rounded-xl">
<AdminIcon />
</div>
<div>
<h1 className="text-2xl font-bold text-dark-100">{t('admin.panel.title')}</h1>
<p className="text-dark-400">{t('admin.panel.subtitle')}</p>
</div>
</div>
{/* Grid of admin sections */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{adminSections.map((section) => (
<AdminCard key={section.to} {...section} />
))}
</div>
</div>
)
}

470
src/pages/AdminServers.tsx Normal file
View File

@@ -0,0 +1,470 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
serversApi,
ServerListItem,
ServerDetail,
ServerUpdateRequest
} from '../api/servers'
// Icons
const ServerIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
</svg>
)
const SyncIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
)
const EditIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
)
const CheckIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)
const XIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)
const UsersIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
)
// Country flags (simple emoji mapping)
const getCountryFlag = (code: string | null): string => {
if (!code) return ''
const codeMap: Record<string, string> = {
'RU': '🇷🇺', 'US': '🇺🇸', 'DE': '🇩🇪', 'NL': '🇳🇱', 'GB': '🇬🇧',
'FR': '🇫🇷', 'FI': '🇫🇮', 'SE': '🇸🇪', 'PL': '🇵🇱', 'CZ': '🇨🇿',
'AT': '🇦🇹', 'CH': '🇨🇭', 'UA': '🇺🇦', 'KZ': '🇰🇿', 'JP': '🇯🇵',
'KR': '🇰🇷', 'SG': '🇸🇬', 'HK': '🇭🇰', 'CA': '🇨🇦', 'AU': '🇦🇺',
'BR': '🇧🇷', 'IN': '🇮🇳', 'TR': '🇹🇷', 'IL': '🇮🇱', 'AE': '🇦🇪',
}
return codeMap[code.toUpperCase()] || code
}
interface ServerModalProps {
server: ServerDetail
onSave: (data: ServerUpdateRequest) => void
onClose: () => void
isLoading?: boolean
}
function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) {
const { t } = useTranslation()
const [displayName, setDisplayName] = useState(server.display_name)
const [description, setDescription] = useState(server.description || '')
const [countryCode, setCountryCode] = useState(server.country_code || '')
const [priceKopeks, setPriceKopeks] = useState(server.price_kopeks)
const [maxUsers, setMaxUsers] = useState<number | null>(server.max_users)
const [sortOrder, setSortOrder] = useState(server.sort_order)
const handleSubmit = () => {
const data: ServerUpdateRequest = {
display_name: displayName,
description: description || undefined,
country_code: countryCode || undefined,
price_kopeks: priceKopeks,
max_users: maxUsers || undefined,
sort_order: sortOrder,
}
onSave(data)
}
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-dark-800 rounded-xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-700">
<div className="flex items-center gap-2">
<span className="text-xl">{getCountryFlag(server.country_code)}</span>
<h2 className="text-lg font-semibold text-dark-100">
{t('admin.servers.edit')}
</h2>
</div>
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded-lg transition-colors">
<XIcon />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Original Name (readonly) */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.originalName')}</label>
<div className="px-3 py-2 bg-dark-700/50 border border-dark-600 rounded-lg text-dark-400">
{server.original_name || server.squad_uuid}
</div>
</div>
{/* Display Name */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.displayName')}</label>
<input
type="text"
value={displayName}
onChange={e => setDisplayName(e.target.value)}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
placeholder={t('admin.servers.displayNamePlaceholder')}
/>
</div>
{/* Description */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.description')}</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500 resize-none"
rows={2}
placeholder={t('admin.servers.descriptionPlaceholder')}
/>
</div>
{/* Country Code */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.countryCode')}</label>
<input
type="text"
value={countryCode}
onChange={e => setCountryCode(e.target.value.toUpperCase().slice(0, 2))}
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
placeholder="RU"
maxLength={2}
/>
{countryCode && (
<span className="ml-2 text-xl">{getCountryFlag(countryCode)}</span>
)}
</div>
{/* Price */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.price')}</label>
<div className="flex items-center gap-2">
<input
type="number"
value={priceKopeks / 100}
onChange={e => setPriceKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)}
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
step={1}
/>
<span className="text-dark-400"></span>
</div>
<p className="text-xs text-dark-500 mt-1">{t('admin.servers.priceHint')}</p>
</div>
{/* Max Users */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.maxUsers')}</label>
<div className="flex items-center gap-2">
<input
type="number"
value={maxUsers || ''}
onChange={e => setMaxUsers(e.target.value ? Math.max(0, parseInt(e.target.value)) : null)}
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
placeholder={t('admin.servers.unlimited')}
/>
{!maxUsers && (
<span className="text-sm text-dark-400">{t('admin.servers.unlimited')}</span>
)}
</div>
</div>
{/* Sort Order */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.sortOrder')}</label>
<input
type="number"
value={sortOrder}
onChange={e => setSortOrder(parseInt(e.target.value) || 0)}
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
/>
</div>
{/* Stats */}
<div className="pt-4 border-t border-dark-700">
<h4 className="text-sm font-medium text-dark-300 mb-2">{t('admin.servers.stats')}</h4>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="p-2 bg-dark-700/50 rounded-lg">
<span className="text-dark-400">{t('admin.servers.currentUsers')}:</span>
<span className="ml-2 text-dark-200">{server.current_users}</span>
</div>
<div className="p-2 bg-dark-700/50 rounded-lg">
<span className="text-dark-400">{t('admin.servers.activeSubscriptions')}:</span>
<span className="ml-2 text-dark-200">{server.active_subscriptions}</span>
</div>
</div>
{server.tariffs_using.length > 0 && (
<div className="mt-2 p-2 bg-dark-700/50 rounded-lg">
<span className="text-dark-400 text-sm">{t('admin.servers.usedByTariffs')}:</span>
<div className="flex flex-wrap gap-1 mt-1">
{server.tariffs_using.map(tariff => (
<span key={tariff} className="px-2 py-0.5 bg-dark-600 text-dark-300 text-xs rounded">
{tariff}
</span>
))}
</div>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="flex justify-end gap-3 p-4 border-t border-dark-700">
<button
onClick={onClose}
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
>
{t('common.cancel')}
</button>
<button
onClick={handleSubmit}
disabled={!displayName || isLoading}
className="px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? t('common.loading') : t('common.save')}
</button>
</div>
</div>
</div>
)
}
export default function AdminServers() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [editingServer, setEditingServer] = useState<ServerDetail | null>(null)
const [loadingServerId, setLoadingServerId] = useState<number | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
// Queries
const { data: serversData, isLoading } = useQuery({
queryKey: ['admin-servers'],
queryFn: () => serversApi.getServers(true),
})
// Mutations
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: ServerUpdateRequest }) =>
serversApi.updateServer(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-servers'] })
setEditingServer(null)
},
})
const toggleMutation = useMutation({
mutationFn: serversApi.toggleServer,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-servers'] })
},
})
const toggleTrialMutation = useMutation({
mutationFn: serversApi.toggleTrial,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-servers'] })
},
})
const syncMutation = useMutation({
mutationFn: serversApi.syncServers,
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['admin-servers'] })
alert(result.message)
},
})
const handleEdit = async (serverId: number) => {
setLoadingServerId(serverId)
setLoadError(null)
try {
const detail = await serversApi.getServer(serverId)
setEditingServer(detail)
} catch (error: unknown) {
console.error('Failed to load server:', error)
const errorMessage = error instanceof Error ? error.message : t('admin.servers.loadError')
setLoadError(errorMessage)
} finally {
setLoadingServerId(null)
}
}
const handleSave = (data: ServerUpdateRequest) => {
if (editingServer) {
updateMutation.mutate({ id: editingServer.id, data })
}
}
const servers = serversData?.servers || []
return (
<div className="animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-accent-500/20 rounded-lg">
<ServerIcon />
</div>
<div>
<h1 className="text-xl font-semibold text-dark-100">{t('admin.servers.title')}</h1>
<p className="text-sm text-dark-400">{t('admin.servers.subtitle')}</p>
</div>
</div>
<button
onClick={() => syncMutation.mutate()}
disabled={syncMutation.isPending}
className="flex items-center gap-2 px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors disabled:opacity-50"
>
<SyncIcon />
{syncMutation.isPending ? t('admin.servers.syncing') : t('admin.servers.sync')}
</button>
</div>
{/* Servers List */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
) : servers.length === 0 ? (
<div className="text-center py-12">
<p className="text-dark-400">{t('admin.servers.noServers')}</p>
<button
onClick={() => syncMutation.mutate()}
className="mt-4 text-accent-400 hover:text-accent-300"
>
{t('admin.servers.syncNow')}
</button>
</div>
) : (
<div className="space-y-3">
{servers.map((server: ServerListItem) => (
<div
key={server.id}
className={`p-4 bg-dark-800 rounded-xl border transition-colors ${
server.is_available ? 'border-dark-700' : 'border-dark-700/50 opacity-60'
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{getCountryFlag(server.country_code)}</span>
<h3 className="font-medium text-dark-100 truncate">{server.display_name}</h3>
{server.is_trial_eligible && (
<span className="px-2 py-0.5 text-xs bg-success-500/20 text-success-400 rounded">
{t('admin.servers.trial')}
</span>
)}
{!server.is_available && (
<span className="px-2 py-0.5 text-xs bg-dark-600 text-dark-400 rounded">
{t('admin.servers.unavailable')}
</span>
)}
{server.is_full && (
<span className="px-2 py-0.5 text-xs bg-warning-500/20 text-warning-400 rounded">
{t('admin.servers.full')}
</span>
)}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
<span className="flex items-center gap-1">
<UsersIcon />
{server.current_users}
{server.max_users ? ` / ${server.max_users}` : ''}
</span>
<span>{server.price_rubles} </span>
<span className="text-dark-500 text-xs font-mono truncate max-w-[200px]">{server.squad_uuid}</span>
</div>
</div>
<div className="flex items-center gap-2">
{/* Toggle Available */}
<button
onClick={() => toggleMutation.mutate(server.id)}
className={`p-2 rounded-lg transition-colors ${
server.is_available
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
: 'bg-dark-700 text-dark-400 hover:bg-dark-600'
}`}
title={server.is_available ? t('admin.servers.disable') : t('admin.servers.enable')}
>
{server.is_available ? <CheckIcon /> : <XIcon />}
</button>
{/* Toggle Trial */}
<button
onClick={() => toggleTrialMutation.mutate(server.id)}
className={`p-2 rounded-lg transition-colors ${
server.is_trial_eligible
? 'bg-accent-500/20 text-accent-400 hover:bg-accent-500/30'
: 'bg-dark-700 text-dark-400 hover:bg-dark-600'
}`}
title={t('admin.servers.toggleTrial')}
>
T
</button>
{/* Edit */}
<button
onClick={() => handleEdit(server.id)}
disabled={loadingServerId === server.id}
className="p-2 bg-dark-700 text-dark-300 rounded-lg hover:bg-dark-600 hover:text-dark-100 transition-colors disabled:opacity-50"
title={t('admin.servers.edit')}
>
{loadingServerId === server.id ? (
<div className="w-4 h-4 border-2 border-dark-300 border-t-transparent rounded-full animate-spin" />
) : (
<EditIcon />
)}
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Error Toast */}
{loadError && (
<div className="fixed bottom-4 right-4 bg-error-500/90 text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 z-50">
<span>{loadError}</span>
<button
onClick={() => setLoadError(null)}
className="p-1 hover:bg-white/20 rounded"
>
<XIcon />
</button>
</div>
)}
{/* Edit Modal */}
{editingServer && (
<ServerModal
server={editingServer}
onSave={handleSave}
onClose={() => setEditingServer(null)}
isLoading={updateMutation.isPending}
/>
)}
</div>
)
}

1442
src/pages/AdminSettings.tsx Normal file

File diff suppressed because it is too large Load Diff

893
src/pages/AdminTariffs.tsx Normal file
View File

@@ -0,0 +1,893 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
tariffsApi,
TariffListItem,
TariffDetail,
TariffCreateRequest,
TariffUpdateRequest,
PeriodPrice,
ServerInfo,
ServerTrafficLimit
} from '../api/tariffs'
// Icons
const TariffIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
</svg>
)
const PlusIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
)
const EditIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
)
const TrashIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
)
const CheckIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)
const XIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)
const InfinityIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 12a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zm0 0c0 1.657 1.007 3 2.25 3S21 13.657 21 12a9 9 0 10-2.636 6.364M16.5 12V8.25" />
</svg>
)
// Default period options
const DEFAULT_PERIODS = [7, 14, 30, 90, 180, 365]
interface TariffModalProps {
tariff?: TariffDetail | null
servers: ServerInfo[]
onSave: (data: TariffCreateRequest | TariffUpdateRequest) => void
onClose: () => void
isLoading?: boolean
}
function TariffModal({ tariff, servers, onSave, onClose, isLoading }: TariffModalProps) {
const { t } = useTranslation()
const isEdit = !!tariff
const [name, setName] = useState(tariff?.name || '')
const [description, setDescription] = useState(tariff?.description || '')
const [trafficLimitGb, setTrafficLimitGb] = useState(tariff?.traffic_limit_gb || 0)
const [deviceLimit, setDeviceLimit] = useState(tariff?.device_limit || 1)
const [devicePriceKopeks, setDevicePriceKopeks] = useState(tariff?.device_price_kopeks || 0)
const [tierLevel, setTierLevel] = useState(tariff?.tier_level || 1)
const [periodPrices, setPeriodPrices] = useState<PeriodPrice[]>(
tariff?.period_prices || DEFAULT_PERIODS.map(d => ({ days: d, price_kopeks: 0 }))
)
const [selectedSquads, setSelectedSquads] = useState<string[]>(tariff?.allowed_squads || [])
const [serverTrafficLimits, setServerTrafficLimits] = useState<Record<string, ServerTrafficLimit>>(
tariff?.server_traffic_limits || {}
)
// Произвольное количество дней
const [customDaysEnabled, setCustomDaysEnabled] = useState(tariff?.custom_days_enabled || false)
const [pricePerDayKopeks, setPricePerDayKopeks] = useState(tariff?.price_per_day_kopeks || 0)
const [minDays, setMinDays] = useState(tariff?.min_days || 1)
const [maxDays, setMaxDays] = useState(tariff?.max_days || 365)
// Произвольный трафик
const [customTrafficEnabled, setCustomTrafficEnabled] = useState(tariff?.custom_traffic_enabled || false)
const [trafficPricePerGbKopeks, setTrafficPricePerGbKopeks] = useState(tariff?.traffic_price_per_gb_kopeks || 0)
const [minTrafficGb, setMinTrafficGb] = useState(tariff?.min_traffic_gb || 1)
const [maxTrafficGb, setMaxTrafficGb] = useState(tariff?.max_traffic_gb || 1000)
// Докупка трафика
const [trafficTopupEnabled, setTrafficTopupEnabled] = useState(tariff?.traffic_topup_enabled || false)
const [maxTopupTrafficGb, setMaxTopupTrafficGb] = useState(tariff?.max_topup_traffic_gb || 0)
const [trafficTopupPackages, setTrafficTopupPackages] = useState<Record<string, number>>(
tariff?.traffic_topup_packages || {}
)
// Дневной тариф
const [isDaily, setIsDaily] = useState(tariff?.is_daily || false)
const [dailyPriceKopeks, setDailyPriceKopeks] = useState(tariff?.daily_price_kopeks || 0)
const [activeTab, setActiveTab] = useState<'basic' | 'prices' | 'servers' | 'custom'>('basic')
const handleSubmit = () => {
// Фильтруем лимиты только для выбранных серверов и только если они заданы (> 0)
const filteredLimits: Record<string, ServerTrafficLimit> = {}
for (const uuid of selectedSquads) {
if (serverTrafficLimits[uuid] && serverTrafficLimits[uuid].traffic_limit_gb > 0) {
filteredLimits[uuid] = serverTrafficLimits[uuid]
}
}
const data: TariffCreateRequest | TariffUpdateRequest = {
name,
description: description || undefined,
traffic_limit_gb: trafficLimitGb,
device_limit: deviceLimit,
device_price_kopeks: devicePriceKopeks > 0 ? devicePriceKopeks : undefined,
tier_level: tierLevel,
period_prices: periodPrices.filter(p => p.price_kopeks > 0),
allowed_squads: selectedSquads,
server_traffic_limits: Object.keys(filteredLimits).length > 0 ? filteredLimits : {},
// Произвольное количество дней
custom_days_enabled: customDaysEnabled,
price_per_day_kopeks: pricePerDayKopeks,
min_days: minDays,
max_days: maxDays,
// Произвольный трафик
custom_traffic_enabled: customTrafficEnabled,
traffic_price_per_gb_kopeks: trafficPricePerGbKopeks,
min_traffic_gb: minTrafficGb,
max_traffic_gb: maxTrafficGb,
// Докупка трафика
traffic_topup_enabled: trafficTopupEnabled,
traffic_topup_packages: trafficTopupPackages,
max_topup_traffic_gb: maxTopupTrafficGb,
// Дневной тариф
is_daily: isDaily,
daily_price_kopeks: dailyPriceKopeks,
}
onSave(data)
}
const updateServerTrafficLimit = (uuid: string, limitGb: number) => {
setServerTrafficLimits(prev => ({
...prev,
[uuid]: { traffic_limit_gb: limitGb }
}))
}
const toggleServer = (uuid: string) => {
setSelectedSquads(prev =>
prev.includes(uuid)
? prev.filter(s => s !== uuid)
: [...prev, uuid]
)
}
const updatePeriodPrice = (days: number, priceKopeks: number) => {
setPeriodPrices(prev => {
const existing = prev.find(p => p.days === days)
if (existing) {
return prev.map(p => p.days === days ? { ...p, price_kopeks: priceKopeks } : p)
}
return [...prev, { days, price_kopeks: priceKopeks }]
})
}
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-dark-800 rounded-xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-700">
<h2 className="text-lg font-semibold text-dark-100">
{isEdit ? t('admin.tariffs.edit') : t('admin.tariffs.create')}
</h2>
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded-lg transition-colors">
<XIcon />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-dark-700">
{(['basic', 'prices', 'servers', 'custom'] as const).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeTab === tab
? 'text-accent-400 border-b-2 border-accent-400'
: 'text-dark-400 hover:text-dark-200'
}`}
>
{tab === 'basic' && t('admin.tariffs.tabs.basic')}
{tab === 'prices' && t('admin.tariffs.tabs.prices')}
{tab === 'servers' && t('admin.tariffs.tabs.servers')}
{tab === 'custom' && 'Гибкие опции'}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{activeTab === 'basic' && (
<div className="space-y-4">
{/* Name */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.tariffs.name')}</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
placeholder={t('admin.tariffs.namePlaceholder')}
/>
</div>
{/* Description */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.tariffs.description')}</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500 resize-none"
rows={3}
placeholder={t('admin.tariffs.descriptionPlaceholder')}
/>
</div>
{/* Traffic Limit */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.tariffs.trafficLimit')}</label>
<div className="flex items-center gap-2">
<input
type="number"
value={trafficLimitGb}
onChange={e => setTrafficLimitGb(Math.max(0, parseInt(e.target.value) || 0))}
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
/>
<span className="text-dark-400">GB</span>
{trafficLimitGb === 0 && (
<span className="flex items-center gap-1 text-sm text-success-500">
<InfinityIcon />
{t('admin.tariffs.unlimited')}
</span>
)}
</div>
<p className="text-xs text-dark-500 mt-1">{t('admin.tariffs.trafficHint')}</p>
</div>
{/* Device Limit */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.tariffs.deviceLimit')}</label>
<input
type="number"
value={deviceLimit}
onChange={e => setDeviceLimit(Math.max(1, parseInt(e.target.value) || 1))}
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={1}
/>
</div>
{/* Tier Level */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.tariffs.tierLevel')}</label>
<input
type="number"
value={tierLevel}
onChange={e => setTierLevel(Math.min(10, Math.max(1, parseInt(e.target.value) || 1)))}
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={1}
max={10}
/>
<p className="text-xs text-dark-500 mt-1">{t('admin.tariffs.tierHint')}</p>
</div>
</div>
)}
{activeTab === 'prices' && (
<div className="space-y-3">
<p className="text-sm text-dark-400 mb-4">{t('admin.tariffs.pricesHint')}</p>
{DEFAULT_PERIODS.map(days => {
const price = periodPrices.find(p => p.days === days)?.price_kopeks || 0
const isEnabled = price > 0
return (
<div
key={days}
className={`flex items-center gap-3 p-3 rounded-lg transition-colors ${
isEnabled
? 'bg-dark-700/50'
: 'bg-dark-800/30 opacity-60'
}`}
>
{/* Toggle */}
<button
type="button"
onClick={() => {
if (isEnabled) {
updatePeriodPrice(days, 0)
} else {
updatePeriodPrice(days, 10000) // Default 100₽
}
}}
className={`w-10 h-6 rounded-full transition-colors relative ${
isEnabled ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
isEnabled ? 'left-5' : 'left-1'
}`}
/>
</button>
<span className="w-20 text-dark-300">{days} {t('admin.tariffs.days')}</span>
<input
type="number"
value={price / 100}
onChange={e => updatePeriodPrice(days, Math.max(0, parseFloat(e.target.value) || 0) * 100)}
disabled={!isEnabled}
className={`w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500 ${
!isEnabled ? 'opacity-50 cursor-not-allowed' : ''
}`}
min={0}
step={1}
/>
<span className="text-dark-400"></span>
{!isEnabled && (
<span className="text-xs text-dark-500">{t('admin.tariffs.periodDisabled')}</span>
)}
</div>
)
})}
</div>
)}
{activeTab === 'servers' && (
<div className="space-y-2">
<p className="text-sm text-dark-400 mb-4">{t('admin.tariffs.serversHint')}</p>
{servers.length === 0 ? (
<p className="text-dark-500 text-center py-4">{t('admin.tariffs.noServers')}</p>
) : (
servers.map(server => {
const isSelected = selectedSquads.includes(server.squad_uuid)
const serverLimit = serverTrafficLimits[server.squad_uuid]?.traffic_limit_gb || 0
return (
<div
key={server.id}
className={`p-3 rounded-lg transition-colors ${
isSelected
? 'bg-accent-500/20 border border-accent-500/50'
: 'bg-dark-700 hover:bg-dark-600 border border-transparent'
}`}
>
<div
onClick={() => toggleServer(server.squad_uuid)}
className="flex items-center gap-3 cursor-pointer"
>
<div className={`w-5 h-5 rounded flex items-center justify-center ${
isSelected
? 'bg-accent-500 text-white'
: 'bg-dark-600'
}`}>
{isSelected && <CheckIcon />}
</div>
<span className="text-dark-200 flex-1">{server.display_name}</span>
{server.country_code && (
<span className="text-xs text-dark-500">{server.country_code}</span>
)}
</div>
{/* Лимит трафика для сервера */}
{isSelected && (
<div className="mt-2 ml-8 flex items-center gap-2">
<span className="text-xs text-dark-400">{t('admin.tariffs.serverTrafficLimit')}:</span>
<input
type="number"
value={serverLimit}
onClick={e => e.stopPropagation()}
onChange={e => {
e.stopPropagation()
updateServerTrafficLimit(server.squad_uuid, Math.max(0, parseInt(e.target.value) || 0))
}}
className="w-20 px-2 py-1 bg-dark-600 border border-dark-500 rounded text-sm text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
placeholder="0"
/>
<span className="text-xs text-dark-400">GB</span>
{serverLimit === 0 && (
<span className="text-xs text-dark-500">({t('admin.tariffs.useDefault')})</span>
)}
</div>
)}
</div>
)
})
)}
</div>
)}
{activeTab === 'custom' && (
<div className="space-y-6">
{/* Дневной тариф */}
<div className="p-4 bg-dark-700/50 rounded-lg border border-amber-500/30">
<div className="flex items-center justify-between mb-3">
<div>
<h4 className="text-sm font-medium text-amber-400">🌙 Дневной тариф</h4>
<p className="text-xs text-dark-500 mt-1">Ежедневное списание с баланса. Можно ставить на паузу.</p>
</div>
<button
type="button"
onClick={() => setIsDaily(!isDaily)}
className={`w-10 h-6 rounded-full transition-colors relative ${
isDaily ? 'bg-amber-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
isDaily ? 'left-5' : 'left-1'
}`}
/>
</button>
</div>
{isDaily && (
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Цена за день:</span>
<input
type="number"
value={dailyPriceKopeks / 100}
onChange={e => setDailyPriceKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-amber-500"
min={0}
step={0.1}
/>
<span className="text-dark-400">/день</span>
</div>
)}
</div>
{/* Цена за доп. устройство */}
<div className="p-4 bg-dark-700/50 rounded-lg">
<h4 className="text-sm font-medium text-dark-200 mb-3">Докупка устройств</h4>
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400">Цена за устройство (30 дней):</span>
<input
type="number"
value={devicePriceKopeks / 100}
onChange={e => setDevicePriceKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
step={1}
/>
<span className="text-dark-400"></span>
</div>
<p className="text-xs text-dark-500 mt-1">0 = докупка недоступна</p>
</div>
{/* Произвольное количество дней */}
<div className="p-4 bg-dark-700/50 rounded-lg">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-dark-200">Произвольное кол-во дней</h4>
<button
type="button"
onClick={() => setCustomDaysEnabled(!customDaysEnabled)}
className={`w-10 h-6 rounded-full transition-colors relative ${
customDaysEnabled ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
customDaysEnabled ? 'left-5' : 'left-1'
}`}
/>
</button>
</div>
{customDaysEnabled && (
<div className="space-y-3">
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Цена за день:</span>
<input
type="number"
value={pricePerDayKopeks / 100}
onChange={e => setPricePerDayKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
step={0.1}
/>
<span className="text-dark-400"></span>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Мин. дней:</span>
<input
type="number"
value={minDays}
onChange={e => setMinDays(Math.max(1, parseInt(e.target.value) || 1))}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={1}
/>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Макс. дней:</span>
<input
type="number"
value={maxDays}
onChange={e => setMaxDays(Math.max(1, parseInt(e.target.value) || 365))}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={1}
/>
</div>
</div>
)}
</div>
{/* Произвольный трафик */}
<div className="p-4 bg-dark-700/50 rounded-lg">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-dark-200">Произвольный трафик при покупке</h4>
<button
type="button"
onClick={() => setCustomTrafficEnabled(!customTrafficEnabled)}
className={`w-10 h-6 rounded-full transition-colors relative ${
customTrafficEnabled ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
customTrafficEnabled ? 'left-5' : 'left-1'
}`}
/>
</button>
</div>
{customTrafficEnabled && (
<div className="space-y-3">
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Цена за 1 ГБ:</span>
<input
type="number"
value={trafficPricePerGbKopeks / 100}
onChange={e => setTrafficPricePerGbKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
step={0.1}
/>
<span className="text-dark-400"></span>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Мин. ГБ:</span>
<input
type="number"
value={minTrafficGb}
onChange={e => setMinTrafficGb(Math.max(1, parseInt(e.target.value) || 1))}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={1}
/>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Макс. ГБ:</span>
<input
type="number"
value={maxTrafficGb}
onChange={e => setMaxTrafficGb(Math.max(1, parseInt(e.target.value) || 1000))}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={1}
/>
</div>
</div>
)}
</div>
{/* Докупка трафика */}
<div className="p-4 bg-dark-700/50 rounded-lg">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-dark-200">Докупка трафика (после покупки)</h4>
<button
type="button"
onClick={() => setTrafficTopupEnabled(!trafficTopupEnabled)}
className={`w-10 h-6 rounded-full transition-colors relative ${
trafficTopupEnabled ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
trafficTopupEnabled ? 'left-5' : 'left-1'
}`}
/>
</button>
</div>
{trafficTopupEnabled && (
<div className="space-y-3">
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Макс. лимит:</span>
<input
type="number"
value={maxTopupTrafficGb}
onChange={e => setMaxTopupTrafficGb(Math.max(0, parseInt(e.target.value) || 0))}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
/>
<span className="text-dark-400">ГБ</span>
<span className="text-xs text-dark-500">(0 = без ограничений)</span>
</div>
<div className="mt-3">
<span className="text-sm text-dark-400">Пакеты трафика (ГБ: цена ):</span>
<div className="mt-2 grid grid-cols-2 gap-2">
{[5, 10, 20, 50].map(gb => (
<div key={gb} className="flex items-center gap-2">
<span className="text-sm text-dark-300 w-12">{gb} ГБ:</span>
<input
type="number"
value={(trafficTopupPackages[String(gb)] || 0) / 100}
onChange={e => {
const price = Math.max(0, parseFloat(e.target.value) || 0) * 100
setTrafficTopupPackages(prev => ({
...prev,
[String(gb)]: price
}))
}}
className="w-20 px-2 py-1 bg-dark-600 border border-dark-500 rounded text-sm text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
step={1}
/>
<span className="text-xs text-dark-400"></span>
</div>
))}
</div>
</div>
</div>
)}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-3 p-4 border-t border-dark-700">
<button
onClick={onClose}
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
>
{t('common.cancel')}
</button>
<button
onClick={handleSubmit}
disabled={!name || isLoading}
className="px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? t('common.loading') : t('common.save')}
</button>
</div>
</div>
</div>
)
}
export default function AdminTariffs() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [showModal, setShowModal] = useState(false)
const [editingTariff, setEditingTariff] = useState<TariffDetail | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null)
// Queries
const { data: tariffsData, isLoading } = useQuery({
queryKey: ['admin-tariffs'],
queryFn: () => tariffsApi.getTariffs(true),
})
const { data: servers = [] } = useQuery({
queryKey: ['admin-tariffs-servers'],
queryFn: () => tariffsApi.getAvailableServers(),
})
// Mutations
const createMutation = useMutation({
mutationFn: tariffsApi.createTariff,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-tariffs'] })
setShowModal(false)
},
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: TariffUpdateRequest }) =>
tariffsApi.updateTariff(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-tariffs'] })
setShowModal(false)
setEditingTariff(null)
},
})
const deleteMutation = useMutation({
mutationFn: tariffsApi.deleteTariff,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-tariffs'] })
setDeleteConfirm(null)
},
})
const toggleMutation = useMutation({
mutationFn: tariffsApi.toggleTariff,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-tariffs'] })
},
})
const toggleTrialMutation = useMutation({
mutationFn: tariffsApi.toggleTrial,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-tariffs'] })
},
})
const handleEdit = async (tariffId: number) => {
try {
const detail = await tariffsApi.getTariff(tariffId)
setEditingTariff(detail)
setShowModal(true)
} catch (error) {
console.error('Failed to load tariff:', error)
}
}
const handleSave = (data: TariffCreateRequest | TariffUpdateRequest) => {
if (editingTariff) {
updateMutation.mutate({ id: editingTariff.id, data })
} else {
createMutation.mutate(data as TariffCreateRequest)
}
}
const tariffs = tariffsData?.tariffs || []
return (
<div className="animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-accent-500/20 rounded-lg">
<TariffIcon />
</div>
<div>
<h1 className="text-xl font-semibold text-dark-100">{t('admin.tariffs.title')}</h1>
<p className="text-sm text-dark-400">{t('admin.tariffs.subtitle')}</p>
</div>
</div>
<button
onClick={() => { setEditingTariff(null); setShowModal(true) }}
className="flex items-center gap-2 px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors"
>
<PlusIcon />
{t('admin.tariffs.create')}
</button>
</div>
{/* Tariffs List */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
) : tariffs.length === 0 ? (
<div className="text-center py-12">
<p className="text-dark-400">{t('admin.tariffs.noTariffs')}</p>
</div>
) : (
<div className="space-y-3">
{tariffs.map((tariff: TariffListItem) => (
<div
key={tariff.id}
className={`p-4 bg-dark-800 rounded-xl border transition-colors ${
tariff.is_active ? 'border-dark-700' : 'border-dark-700/50 opacity-60'
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-dark-100 truncate">{tariff.name}</h3>
{tariff.is_trial_available && (
<span className="px-2 py-0.5 text-xs bg-success-500/20 text-success-400 rounded">
{t('admin.tariffs.trial')}
</span>
)}
{!tariff.is_active && (
<span className="px-2 py-0.5 text-xs bg-dark-600 text-dark-400 rounded">
{t('admin.tariffs.inactive')}
</span>
)}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
<span>
{tariff.traffic_limit_gb === 0
? t('admin.tariffs.unlimited')
: `${tariff.traffic_limit_gb} GB`
}
</span>
<span>{tariff.device_limit} {t('admin.tariffs.devices')}</span>
<span>{tariff.servers_count} {t('admin.tariffs.servers')}</span>
<span>{tariff.subscriptions_count} {t('admin.tariffs.subscriptions')}</span>
</div>
</div>
<div className="flex items-center gap-2">
{/* Toggle Active */}
<button
onClick={() => toggleMutation.mutate(tariff.id)}
className={`p-2 rounded-lg transition-colors ${
tariff.is_active
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
: 'bg-dark-700 text-dark-400 hover:bg-dark-600'
}`}
title={tariff.is_active ? t('admin.tariffs.deactivate') : t('admin.tariffs.activate')}
>
{tariff.is_active ? <CheckIcon /> : <XIcon />}
</button>
{/* Toggle Trial */}
<button
onClick={() => toggleTrialMutation.mutate(tariff.id)}
className={`p-2 rounded-lg transition-colors ${
tariff.is_trial_available
? 'bg-accent-500/20 text-accent-400 hover:bg-accent-500/30'
: 'bg-dark-700 text-dark-400 hover:bg-dark-600'
}`}
title={t('admin.tariffs.toggleTrial')}
>
T
</button>
{/* Edit */}
<button
onClick={() => handleEdit(tariff.id)}
className="p-2 bg-dark-700 text-dark-300 rounded-lg hover:bg-dark-600 hover:text-dark-100 transition-colors"
title={t('admin.tariffs.edit')}
>
<EditIcon />
</button>
{/* Delete */}
<button
onClick={() => setDeleteConfirm(tariff.id)}
className="p-2 bg-dark-700 text-dark-300 rounded-lg hover:bg-error-500/20 hover:text-error-400 transition-colors"
title={t('admin.tariffs.delete')}
disabled={tariff.subscriptions_count > 0}
>
<TrashIcon />
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Create/Edit Modal */}
{showModal && (
<TariffModal
tariff={editingTariff}
servers={servers}
onSave={handleSave}
onClose={() => { setShowModal(false); setEditingTariff(null) }}
isLoading={createMutation.isPending || updateMutation.isPending}
/>
)}
{/* Delete Confirmation */}
{deleteConfirm !== null && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-dark-800 rounded-xl p-6 max-w-sm w-full">
<h3 className="text-lg font-semibold text-dark-100 mb-2">{t('admin.tariffs.confirmDelete')}</h3>
<p className="text-dark-400 mb-6">{t('admin.tariffs.confirmDeleteText')}</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setDeleteConfirm(null)}
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
>
{t('common.cancel')}
</button>
<button
onClick={() => deleteMutation.mutate(deleteConfirm)}
className="px-4 py-2 bg-error-500 text-white rounded-lg hover:bg-error-600 transition-colors"
>
{t('common.delete')}
</button>
</div>
</div>
</div>
)}
</div>
)
}

385
src/pages/AdminTickets.tsx Normal file
View File

@@ -0,0 +1,385 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { adminApi, AdminTicket, AdminTicketDetail, AdminTicketMessage } from '../api/admin'
import { ticketsApi } from '../api/tickets'
function AdminMessageMedia({ message, t }: { message: AdminTicketMessage; t: (key: string) => string }) {
const [imageLoaded, setImageLoaded] = useState(false)
const [imageError, setImageError] = useState(false)
const [showFullImage, setShowFullImage] = useState(false)
if (!message.has_media || !message.media_file_id) {
return null
}
const mediaUrl = ticketsApi.getMediaUrl(message.media_file_id)
if (message.media_type === 'photo') {
return (
<div className="mt-3">
{!imageLoaded && !imageError && (
<div className="w-full h-40 bg-dark-800 rounded-lg animate-pulse flex items-center justify-center">
<div className="w-6 h-6 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)}
{imageError ? (
<div className="w-full h-32 bg-dark-800 rounded-lg flex items-center justify-center text-dark-400 text-sm">
{t('support.imageLoadFailed')}
</div>
) : (
<img
src={mediaUrl}
alt={message.media_caption || 'Attached image'}
className={`max-w-full max-h-64 rounded-lg cursor-pointer hover:opacity-90 transition-opacity ${
imageLoaded ? '' : 'hidden'
}`}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
onClick={() => setShowFullImage(true)}
/>
)}
{message.media_caption && (
<p className="text-xs text-dark-400 mt-1">{message.media_caption}</p>
)}
{showFullImage && (
<div
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4"
onClick={() => setShowFullImage(false)}
>
<button
className="absolute top-4 right-4 text-white/70 hover:text-white"
onClick={() => setShowFullImage(false)}
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<img src={mediaUrl} alt={message.media_caption || 'Attached image'} className="max-w-full max-h-full object-contain" />
</div>
)}
</div>
)
}
return (
<div className="mt-3">
<a
href={mediaUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-2 bg-dark-700 hover:bg-dark-600 rounded-lg text-sm text-dark-200 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
{message.media_caption || `Download ${message.media_type}`}
</a>
</div>
)
}
export default function AdminTickets() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [selectedTicketId, setSelectedTicketId] = useState<number | null>(null)
const [statusFilter, setStatusFilter] = useState<string>('')
const [replyText, setReplyText] = useState('')
const [page, setPage] = useState(1)
const { data: stats } = useQuery({
queryKey: ['admin-ticket-stats'],
queryFn: adminApi.getTicketStats,
})
const { data: ticketsData, isLoading: ticketsLoading } = useQuery({
queryKey: ['admin-tickets', page, statusFilter],
queryFn: () => adminApi.getTickets({
page,
per_page: 20,
status: statusFilter || undefined,
}),
})
const { data: selectedTicket, isLoading: ticketLoading } = useQuery({
queryKey: ['admin-ticket', selectedTicketId],
queryFn: () => adminApi.getTicket(selectedTicketId!),
enabled: !!selectedTicketId,
})
const replyMutation = useMutation({
mutationFn: ({ ticketId, message }: { ticketId: number; message: string }) =>
adminApi.replyToTicket(ticketId, message),
onSuccess: () => {
setReplyText('')
queryClient.invalidateQueries({ queryKey: ['admin-ticket', selectedTicketId] })
queryClient.invalidateQueries({ queryKey: ['admin-tickets'] })
queryClient.invalidateQueries({ queryKey: ['admin-ticket-stats'] })
},
})
const statusMutation = useMutation({
mutationFn: ({ ticketId, status }: { ticketId: number; status: string }) =>
adminApi.updateTicketStatus(ticketId, status),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-ticket', selectedTicketId] })
queryClient.invalidateQueries({ queryKey: ['admin-tickets'] })
queryClient.invalidateQueries({ queryKey: ['admin-ticket-stats'] })
},
})
const handleReply = (e: React.FormEvent) => {
e.preventDefault()
if (!selectedTicketId || !replyText.trim()) return
replyMutation.mutate({ ticketId: selectedTicketId, message: replyText })
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'open': return 'badge-info'
case 'pending': return 'badge-warning'
case 'answered': return 'badge-success'
case 'closed': return 'badge-neutral'
default: return 'badge-neutral'
}
}
const getPriorityBadge = (priority: string) => {
switch (priority) {
case 'urgent': return 'badge-error'
case 'high': return 'badge-warning'
default: return 'badge-neutral'
}
}
const formatUser = (ticket: AdminTicket | AdminTicketDetail) => {
if (!ticket.user) return 'Unknown'
const { first_name, last_name, username, telegram_id } = ticket.user
if (first_name || last_name) return `${first_name || ''} ${last_name || ''}`.trim()
if (username) return `@${username}`
return `ID: ${telegram_id}`
}
return (
<div className="space-y-6">
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('admin.tickets.title')}</h1>
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
<div className="card text-center">
<div className="stat-value">{stats.total}</div>
<div className="stat-label">{t('admin.tickets.total')}</div>
</div>
<div className="card text-center">
<div className="stat-value text-accent-400">{stats.open}</div>
<div className="stat-label">{t('admin.tickets.statusOpen')}</div>
</div>
<div className="card text-center">
<div className="stat-value text-warning-400">{stats.pending}</div>
<div className="stat-label">{t('admin.tickets.statusPending')}</div>
</div>
<div className="card text-center">
<div className="stat-value text-success-400">{stats.answered}</div>
<div className="stat-label">{t('admin.tickets.statusAnswered')}</div>
</div>
<div className="card text-center col-span-2 sm:col-span-1">
<div className="stat-value text-dark-400">{stats.closed}</div>
<div className="stat-label">{t('admin.tickets.statusClosed')}</div>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Ticket List */}
<div className="lg:col-span-1 card">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold text-dark-100">{t('admin.tickets.list')}</h2>
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1) }}
className="input py-1.5 px-3 w-auto text-sm"
>
<option value="">{t('admin.tickets.allStatuses')}</option>
<option value="open">{t('admin.tickets.statusOpen')}</option>
<option value="pending">{t('admin.tickets.statusPending')}</option>
<option value="answered">{t('admin.tickets.statusAnswered')}</option>
<option value="closed">{t('admin.tickets.statusClosed')}</option>
</select>
</div>
{ticketsLoading ? (
<div className="flex justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
) : ticketsData?.items.length === 0 ? (
<div className="text-center py-12 text-dark-500">{t('admin.tickets.noTickets')}</div>
) : (
<div className="space-y-2 max-h-[500px] overflow-y-auto scrollbar-hide">
{ticketsData?.items.map((ticket) => (
<button
key={ticket.id}
onClick={() => setSelectedTicketId(ticket.id)}
className={`w-full text-left p-4 rounded-xl border transition-all ${
selectedTicketId === ticket.id
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
}`}
>
<div className="flex justify-between items-start gap-2 mb-2">
<span className="text-dark-100 font-medium truncate">
#{ticket.id} {ticket.title}
</span>
<span className={getStatusBadge(ticket.status)}>
{t(`admin.tickets.status${ticket.status.charAt(0).toUpperCase() + ticket.status.slice(1)}`)}
</span>
</div>
<div className="text-xs text-dark-500">
{formatUser(ticket)} | {new Date(ticket.updated_at).toLocaleDateString()}
</div>
{ticket.last_message && (
<div className="text-xs text-dark-600 mt-1 truncate">
{ticket.last_message.is_from_admin ? t('admin.tickets.you') : t('admin.tickets.user')}:{' '}
{ticket.last_message.message_text.substring(0, 50)}...
</div>
)}
</button>
))}
</div>
)}
{ticketsData && ticketsData.pages > 1 && (
<div className="flex justify-center items-center gap-3 mt-4 pt-4 border-t border-dark-800/50">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="btn-secondary text-sm py-1.5 px-3 disabled:opacity-50"
>
{t('common.back')}
</button>
<span className="text-sm text-dark-400">{page} / {ticketsData.pages}</span>
<button
onClick={() => setPage((p) => Math.min(ticketsData.pages, p + 1))}
disabled={page === ticketsData.pages}
className="btn-secondary text-sm py-1.5 px-3 disabled:opacity-50"
>
{t('common.next')}
</button>
</div>
)}
</div>
{/* Ticket Detail */}
<div className="lg:col-span-2 card">
{!selectedTicketId ? (
<div className="flex flex-col items-center justify-center h-64">
<div className="w-16 h-16 mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
<svg className="w-8 h-8 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" />
</svg>
</div>
<div className="text-dark-400">{t('admin.tickets.selectTicket')}</div>
</div>
) : ticketLoading ? (
<div className="flex justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
) : selectedTicket ? (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b border-dark-800/50 pb-4 mb-4">
<div className="flex justify-between items-start mb-3">
<h3 className="text-lg font-semibold text-dark-100">
#{selectedTicket.id} {selectedTicket.title}
</h3>
<div className="flex gap-2">
<span className={getStatusBadge(selectedTicket.status)}>
{t(`admin.tickets.status${selectedTicket.status.charAt(0).toUpperCase() + selectedTicket.status.slice(1)}`)}
</span>
<span className={getPriorityBadge(selectedTicket.priority)}>
{selectedTicket.priority}
</span>
</div>
</div>
<div className="text-sm text-dark-500 mb-4">
{t('admin.tickets.from')}: {formatUser(selectedTicket)} |{' '}
{t('admin.tickets.created')}: {new Date(selectedTicket.created_at).toLocaleString()}
</div>
<div className="flex flex-wrap gap-2">
{['open', 'pending', 'answered', 'closed'].map((s) => (
<button
key={s}
onClick={() => statusMutation.mutate({ ticketId: selectedTicket.id, status: s })}
disabled={selectedTicket.status === s || statusMutation.isPending}
className={`px-3 py-1.5 text-xs rounded-lg border transition-all ${
selectedTicket.status === s
? 'bg-accent-500/20 border-accent-500/50 text-accent-400'
: 'border-dark-700/50 text-dark-400 hover:border-dark-600 hover:text-dark-200'
} disabled:opacity-50`}
>
{t(`admin.tickets.status${s.charAt(0).toUpperCase() + s.slice(1)}`)}
</button>
))}
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto space-y-4 max-h-[400px] mb-4 scrollbar-hide">
{selectedTicket.messages.map((msg) => (
<div
key={msg.id}
className={`p-4 rounded-xl ${
msg.is_from_admin
? 'bg-accent-500/10 border border-accent-500/20 ml-4'
: 'bg-dark-800/50 border border-dark-700/30 mr-4'
}`}
>
<div className="flex justify-between items-center mb-2">
<span className={`text-xs font-medium ${msg.is_from_admin ? 'text-accent-400' : 'text-dark-400'}`}>
{msg.is_from_admin ? t('admin.tickets.adminLabel') : t('admin.tickets.userLabel')}
</span>
<span className="text-xs text-dark-500">
{new Date(msg.created_at).toLocaleString()}
</span>
</div>
<p className="text-dark-200 whitespace-pre-wrap">{msg.message_text}</p>
<AdminMessageMedia message={msg} t={t} />
</div>
))}
</div>
{/* Reply form */}
{selectedTicket.status !== 'closed' && (
<form onSubmit={handleReply} className="border-t border-dark-800/50 pt-4">
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder={t('admin.tickets.replyPlaceholder')}
rows={3}
className="input resize-none"
/>
<div className="flex justify-end mt-3">
<button
type="submit"
disabled={!replyText.trim() || replyMutation.isPending}
className="btn-primary"
>
{replyMutation.isPending ? (
<span className="flex items-center gap-2">
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
{t('common.loading')}
</span>
) : (
t('admin.tickets.sendReply')
)}
</button>
</div>
</form>
)}
</div>
) : null}
</div>
</div>
</div>
)
}

650
src/pages/AdminWheel.tsx Normal file
View File

@@ -0,0 +1,650 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { adminWheelApi, type WheelPrizeAdmin } from '../api/wheel'
// Icons
const CogIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
</svg>
)
const GiftIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
)
const ChartIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
)
const PlusIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
)
const TrashIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
)
const PRIZE_TYPE_KEYS = [
{ value: 'subscription_days', key: 'subscription_days', emoji: '📅' },
{ value: 'balance_bonus', key: 'balance_bonus', emoji: '💰' },
{ value: 'traffic_gb', key: 'traffic_gb', emoji: '📊' },
{ value: 'promocode', key: 'promocode', emoji: '🎟️' },
{ value: 'nothing', key: 'nothing', emoji: '😔' },
]
type Tab = 'settings' | 'prizes' | 'statistics'
export default function AdminWheel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [activeTab, setActiveTab] = useState<Tab>('settings')
const [editingPrize, setEditingPrize] = useState<WheelPrizeAdmin | null>(null)
const [isCreating, setIsCreating] = useState(false)
// Fetch config
const { data: config, isLoading } = useQuery({
queryKey: ['admin-wheel-config'],
queryFn: adminWheelApi.getConfig,
})
// Fetch statistics
const { data: stats } = useQuery({
queryKey: ['admin-wheel-stats'],
queryFn: () => adminWheelApi.getStatistics(),
enabled: activeTab === 'statistics',
})
// Update config mutation
const updateConfigMutation = useMutation({
mutationFn: adminWheelApi.updateConfig,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-wheel-config'] })
},
})
// Prize mutations
const createPrizeMutation = useMutation({
mutationFn: adminWheelApi.createPrize,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-wheel-config'] })
setIsCreating(false)
},
})
const updatePrizeMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<WheelPrizeAdmin> }) =>
adminWheelApi.updatePrize(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-wheel-config'] })
setEditingPrize(null)
},
})
const deletePrizeMutation = useMutation({
mutationFn: adminWheelApi.deletePrize,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-wheel-config'] })
},
})
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="w-10 h-10 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (!config) {
return <div className="text-center py-12 text-dark-400">{t('wheel.errors.loadFailed')}</div>
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">
{t('admin.wheel.title')}
</h1>
<div className="flex items-center gap-2">
<span className={`px-3 py-1 rounded-full text-sm ${
config.is_enabled ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'
}`}>
{config.is_enabled ? t('admin.wheel.enabled') : t('admin.wheel.disabled')}
</span>
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 border-b border-dark-700 pb-2">
<button
onClick={() => setActiveTab('settings')}
className={`flex items-center gap-2 px-4 py-2 rounded-t-lg transition-colors ${
activeTab === 'settings'
? 'bg-dark-800 text-accent-400 border-b-2 border-accent-500'
: 'text-dark-400 hover:text-dark-200'
}`}
>
<CogIcon />
{t('admin.wheel.tabs.settings')}
</button>
<button
onClick={() => setActiveTab('prizes')}
className={`flex items-center gap-2 px-4 py-2 rounded-t-lg transition-colors ${
activeTab === 'prizes'
? 'bg-dark-800 text-accent-400 border-b-2 border-accent-500'
: 'text-dark-400 hover:text-dark-200'
}`}
>
<GiftIcon />
{t('admin.wheel.tabs.prizes')} ({config.prizes.length})
</button>
<button
onClick={() => setActiveTab('statistics')}
className={`flex items-center gap-2 px-4 py-2 rounded-t-lg transition-colors ${
activeTab === 'statistics'
? 'bg-dark-800 text-accent-400 border-b-2 border-accent-500'
: 'text-dark-400 hover:text-dark-200'
}`}
>
<ChartIcon />
{t('admin.wheel.tabs.statistics')}
</button>
</div>
{/* Settings Tab */}
{activeTab === 'settings' && (
<div className="card p-6 space-y-6">
{/* Enable toggle */}
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-dark-100">{t('admin.wheel.settings.enableWheel')}</h3>
<p className="text-sm text-dark-400">{t('admin.wheel.settings.allowSpins')}</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={config.is_enabled}
onChange={(e) => updateConfigMutation.mutate({ is_enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-dark-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-accent-500"></div>
</label>
</div>
<hr className="border-dark-700" />
{/* Spin costs */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
{t('admin.wheel.settings.costInStars')}
</label>
<div className="flex gap-2">
<input
type="number"
value={config.spin_cost_stars}
onChange={(e) => updateConfigMutation.mutate({ spin_cost_stars: parseInt(e.target.value) || 1 })}
min={1}
max={1000}
className="input flex-1"
/>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={config.spin_cost_stars_enabled}
onChange={(e) => updateConfigMutation.mutate({ spin_cost_stars_enabled: e.target.checked })}
className="rounded border-dark-600"
/>
<span className="text-sm text-dark-400">{t('admin.wheel.enabled')}</span>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
{t('admin.wheel.settings.costInDays')}
</label>
<div className="flex gap-2">
<input
type="number"
value={config.spin_cost_days}
onChange={(e) => updateConfigMutation.mutate({ spin_cost_days: parseInt(e.target.value) || 1 })}
min={1}
max={30}
className="input flex-1"
/>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={config.spin_cost_days_enabled}
onChange={(e) => updateConfigMutation.mutate({ spin_cost_days_enabled: e.target.checked })}
className="rounded border-dark-600"
/>
<span className="text-sm text-dark-400">{t('admin.wheel.enabled')}</span>
</label>
</div>
</div>
</div>
<hr className="border-dark-700" />
{/* RTP and limits */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
{t('admin.wheel.settings.rtpPercent')}
</label>
<input
type="range"
min={0}
max={100}
value={config.rtp_percent}
onChange={(e) => updateConfigMutation.mutate({ rtp_percent: parseInt(e.target.value) })}
className="w-full"
/>
<div className="flex justify-between text-sm text-dark-400">
<span>0%</span>
<span className="font-bold text-accent-400">{config.rtp_percent}%</span>
<span>100%</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
{t('admin.wheel.settings.dailyLimit')}
</label>
<input
type="number"
value={config.daily_spin_limit}
onChange={(e) => updateConfigMutation.mutate({ daily_spin_limit: parseInt(e.target.value) || 0 })}
min={0}
max={100}
className="input w-full"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
{t('admin.wheel.settings.minSubDays')}
</label>
<input
type="number"
value={config.min_subscription_days_for_day_payment}
onChange={(e) => updateConfigMutation.mutate({ min_subscription_days_for_day_payment: parseInt(e.target.value) || 1 })}
min={1}
max={30}
className="input w-full"
/>
</div>
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
{t('admin.wheel.settings.promoPrefix')}
</label>
<input
type="text"
value={config.promo_prefix}
onChange={(e) => updateConfigMutation.mutate({ promo_prefix: e.target.value })}
maxLength={20}
className="input w-full"
/>
</div>
</div>
</div>
)}
{/* Prizes Tab */}
{activeTab === 'prizes' && (
<div className="space-y-4">
<div className="flex justify-end">
<button
onClick={() => setIsCreating(true)}
className="btn-primary flex items-center gap-2"
>
<PlusIcon />
{t('admin.wheel.prizes.addPrize')}
</button>
</div>
{/* Prize list */}
<div className="space-y-3">
{config.prizes.map((prize) => (
<div
key={prize.id}
className={`card p-4 ${!prize.is_active ? 'opacity-50' : ''}`}
>
<div className="flex items-center gap-4">
<div
className="w-12 h-12 rounded-lg flex items-center justify-center text-2xl"
style={{ backgroundColor: prize.color + '30' }}
>
{prize.emoji}
</div>
<div className="flex-1">
<div className="font-semibold text-dark-100">{prize.display_name}</div>
<div className="text-sm text-dark-400">
{t(`admin.wheel.prizes.types.${prize.prize_type}`)}
{t('admin.wheel.prizes.fields.value')}: {prize.prize_value}
{t('admin.wheel.prizes.fields.worth')}: {(prize.prize_value_kopeks / 100).toFixed(2)}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setEditingPrize(prize)}
className="btn-ghost text-sm"
>
{t('common.edit')}
</button>
<button
onClick={() => {
if (confirm(t('admin.wheel.prizes.deletePrize'))) {
deletePrizeMutation.mutate(prize.id)
}
}}
className="btn-ghost text-red-400"
>
<TrashIcon />
</button>
</div>
</div>
</div>
))}
</div>
{config.prizes.length === 0 && (
<div className="text-center py-12 text-dark-400">
{t('admin.wheel.prizes.noPrizes')}
</div>
)}
</div>
)}
{/* Statistics Tab */}
{activeTab === 'statistics' && stats && (
<div className="space-y-4">
{/* Stats cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="card p-4 text-center">
<div className="text-3xl font-bold text-accent-400">{stats.total_spins}</div>
<div className="text-sm text-dark-400">{t('admin.wheel.statistics.totalSpins')}</div>
</div>
<div className="card p-4 text-center">
<div className="text-3xl font-bold text-green-400">
{(stats.total_revenue_kopeks / 100).toFixed(0)}
</div>
<div className="text-sm text-dark-400">{t('admin.wheel.statistics.revenue')}</div>
</div>
<div className="card p-4 text-center">
<div className="text-3xl font-bold text-yellow-400">
{(stats.total_payout_kopeks / 100).toFixed(0)}
</div>
<div className="text-sm text-dark-400">{t('admin.wheel.statistics.payouts')}</div>
</div>
<div className="card p-4 text-center">
<div className={`text-3xl font-bold ${
stats.actual_rtp_percent <= stats.configured_rtp_percent ? 'text-green-400' : 'text-red-400'
}`}>
{stats.actual_rtp_percent.toFixed(1)}%
</div>
<div className="text-sm text-dark-400">
{t('admin.wheel.statistics.actualRtp')} ({t('admin.wheel.statistics.targetRtp')}: {stats.configured_rtp_percent}%)
</div>
</div>
</div>
{/* Prize distribution */}
{stats.prizes_distribution.length > 0 && (
<div className="card p-4">
<h3 className="font-semibold text-dark-100 mb-3">{t('admin.wheel.statistics.prizeDistribution')}</h3>
<div className="space-y-2">
{stats.prizes_distribution.map((prize, i) => (
<div key={i} className="flex items-center justify-between">
<span className="text-dark-300">{prize.display_name}</span>
<span className="text-dark-100">{prize.count} {t('admin.wheel.statistics.times')}</span>
</div>
))}
</div>
</div>
)}
{/* Top wins */}
{stats.top_wins.length > 0 && (
<div className="card p-4">
<h3 className="font-semibold text-dark-100 mb-3">{t('admin.wheel.statistics.topWins')}</h3>
<div className="space-y-2">
{stats.top_wins.slice(0, 5).map((win, i) => (
<div key={i} className="flex items-center justify-between">
<span className="text-dark-300">
{win.username || `User #${win.user_id}`}
</span>
<span className="text-dark-100">
{win.prize_display_name} ({(win.prize_value_kopeks / 100).toFixed(0)})
</span>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Create/Edit Prize Modal */}
{(isCreating || editingPrize) && (
<PrizeModal
prize={editingPrize}
onClose={() => {
setIsCreating(false)
setEditingPrize(null)
}}
onSave={(data) => {
if (editingPrize) {
updatePrizeMutation.mutate({ id: editingPrize.id, data })
} else {
createPrizeMutation.mutate(data as any)
}
}}
/>
)}
</div>
)
}
// Prize Modal Component
function PrizeModal({
prize,
onClose,
onSave,
}: {
prize: WheelPrizeAdmin | null
onClose: () => void
onSave: (data: Partial<WheelPrizeAdmin>) => void
}) {
const { t } = useTranslation()
const [formData, setFormData] = useState({
prize_type: prize?.prize_type || 'balance_bonus',
prize_value: prize?.prize_value || 0,
display_name: prize?.display_name || '',
emoji: prize?.emoji || '🎁',
color: prize?.color || '#3B82F6',
prize_value_kopeks: prize?.prize_value_kopeks || 0,
is_active: prize?.is_active ?? true,
manual_probability: prize?.manual_probability || null,
promo_balance_bonus_kopeks: prize?.promo_balance_bonus_kopeks || 0,
promo_subscription_days: prize?.promo_subscription_days || 0,
promo_traffic_gb: prize?.promo_traffic_gb || 0,
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSave(formData)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<div className="card p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold text-dark-50 mb-4">
{prize ? t('admin.wheel.prizes.editPrize') : t('admin.wheel.prizes.addPrize')}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Prize type */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">{t('admin.wheel.prizes.fields.type')}</label>
<select
value={formData.prize_type}
onChange={(e) => setFormData({ ...formData, prize_type: e.target.value })}
className="input w-full"
>
{PRIZE_TYPE_KEYS.map((type) => (
<option key={type.value} value={type.value}>
{type.emoji} {t(`admin.wheel.prizes.types.${type.key}`)}
</option>
))}
</select>
</div>
{/* Display name */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">{t('admin.wheel.prizes.fields.displayName')}</label>
<input
type="text"
value={formData.display_name}
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })}
required
maxLength={100}
className="input w-full"
placeholder="e.g. 7 Days Free"
/>
</div>
{/* Prize value */}
{formData.prize_type !== 'nothing' && (
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
{t('admin.wheel.prizes.fields.value')} ({formData.prize_type === 'balance_bonus' ? 'kopeks' : formData.prize_type === 'subscription_days' ? 'days' : 'GB'})
</label>
<input
type="number"
value={formData.prize_value}
onChange={(e) => setFormData({ ...formData, prize_value: parseInt(e.target.value) || 0 })}
min={0}
className="input w-full"
/>
</div>
)}
{/* Prize value in kopeks (for RTP calculation) */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
{t('admin.wheel.prizes.fields.valueKopeks')}
</label>
<input
type="number"
value={formData.prize_value_kopeks}
onChange={(e) => setFormData({ ...formData, prize_value_kopeks: parseInt(e.target.value) || 0 })}
min={0}
className="input w-full"
/>
<p className="text-xs text-dark-500 mt-1">
= {(formData.prize_value_kopeks / 100).toFixed(2)} RUB
</p>
</div>
{/* Emoji and color */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">{t('admin.wheel.prizes.fields.emoji')}</label>
<input
type="text"
value={formData.emoji}
onChange={(e) => setFormData({ ...formData, emoji: e.target.value })}
maxLength={10}
className="input w-full text-center text-2xl"
/>
</div>
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">{t('admin.wheel.prizes.fields.color')}</label>
<div className="flex gap-2">
<input
type="color"
value={formData.color}
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
className="w-12 h-10 rounded cursor-pointer"
/>
<input
type="text"
value={formData.color}
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
className="input flex-1"
pattern="^#[0-9A-Fa-f]{6}$"
/>
</div>
</div>
</div>
{/* Active toggle */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="is_active"
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
className="rounded border-dark-600"
/>
<label htmlFor="is_active" className="text-sm text-dark-300">{t('admin.wheel.prizes.fields.active')}</label>
</div>
{/* Promocode settings */}
{formData.prize_type === 'promocode' && (
<div className="p-3 bg-dark-800 rounded-lg space-y-3">
<h4 className="font-medium text-dark-200">{t('admin.wheel.prizes.promo.title')}</h4>
<div>
<label className="block text-sm text-dark-400 mb-1">{t('admin.wheel.prizes.promo.balanceBonus')}</label>
<input
type="number"
value={formData.promo_balance_bonus_kopeks}
onChange={(e) => setFormData({ ...formData, promo_balance_bonus_kopeks: parseInt(e.target.value) || 0 })}
min={0}
className="input w-full"
/>
</div>
<div>
<label className="block text-sm text-dark-400 mb-1">{t('admin.wheel.prizes.promo.subscriptionDays')}</label>
<input
type="number"
value={formData.promo_subscription_days}
onChange={(e) => setFormData({ ...formData, promo_subscription_days: parseInt(e.target.value) || 0 })}
min={0}
className="input w-full"
/>
</div>
</div>
)}
{/* Buttons */}
<div className="flex gap-3 pt-4">
<button type="button" onClick={onClose} className="btn-secondary flex-1">
{t('common.cancel')}
</button>
<button type="submit" className="btn-primary flex-1">
{prize ? t('common.save') : t('common.confirm')}
</button>
</div>
</form>
</div>
</div>
)
}

292
src/pages/Balance.tsx Normal file
View File

@@ -0,0 +1,292 @@
import { useState, useEffect } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useAuthStore } from '../store/auth'
import { balanceApi } from '../api/balance'
import TopUpModal from '../components/TopUpModal'
import { useCurrency } from '../hooks/useCurrency'
import type { PaymentMethod, PaginatedResponse, Transaction } from '../types'
export default function Balance() {
const { t } = useTranslation()
const { refreshUser } = useAuthStore()
const queryClient = useQueryClient()
const { formatAmount, currencySymbol } = useCurrency()
// Fetch balance directly from API with no caching
const { data: balanceData, refetch: refetchBalance } = useQuery({
queryKey: ['balance'],
queryFn: balanceApi.getBalance,
staleTime: 0, // Always refetch
refetchOnMount: 'always',
})
// Refresh user data on mount to sync balance in store
useEffect(() => {
refreshUser()
}, [])
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod | null>(null)
const [promocode, setPromocode] = useState('')
const [promocodeLoading, setPromocodeLoading] = useState(false)
const [promocodeError, setPromocodeError] = useState<string | null>(null)
const [promocodeSuccess, setPromocodeSuccess] =
useState<{ message: string; amount: number } | null>(null)
const [transactionsPage, setTransactionsPage] = useState(1)
const { data: transactions, isLoading } = useQuery<PaginatedResponse<Transaction>>({
queryKey: ['transactions', transactionsPage],
queryFn: () => balanceApi.getTransactions({ per_page: 20, page: transactionsPage }),
placeholderData: (previousData) => previousData,
})
const { data: paymentMethods } = useQuery({
queryKey: ['payment-methods'],
queryFn: balanceApi.getPaymentMethods,
})
const normalizeType = (type: string) => type?.toUpperCase?.() ?? type
const getTypeBadge = (type: string) => {
switch (normalizeType(type)) {
case 'DEPOSIT':
return 'badge-success'
case 'SUBSCRIPTION_PAYMENT':
return 'badge-info'
case 'REFERRAL_REWARD':
return 'badge-warning'
case 'WITHDRAWAL':
return 'badge-error'
default:
return 'badge-neutral'
}
}
const getTypeLabel = (type: string) => {
switch (normalizeType(type)) {
case 'DEPOSIT':
return t('balance.deposit')
case 'SUBSCRIPTION_PAYMENT':
return t('balance.subscriptionPayment')
case 'REFERRAL_REWARD':
return t('balance.referralReward')
case 'WITHDRAWAL':
return t('balance.withdrawal')
default:
return type
}
}
const handlePromocodeActivate = async () => {
if (!promocode.trim()) return
setPromocodeLoading(true)
setPromocodeError(null)
setPromocodeSuccess(null)
try {
const result = await balanceApi.activatePromocode(promocode.trim())
if (result.success) {
const bonusAmount = result.balance_after - result.balance_before
setPromocodeSuccess({
message: result.bonus_description || t('balance.promocode.success'),
amount: bonusAmount,
})
setTransactionsPage(1)
setPromocode('')
// Refresh balance and transactions
await refetchBalance()
await refreshUser()
queryClient.invalidateQueries({ queryKey: ['transactions'] })
}
} catch (error: unknown) {
const axiosError = error as { response?: { data?: { detail?: string } } }
const errorDetail = axiosError.response?.data?.detail || 'server_error'
// Map backend error messages to translation keys
const errorKey = errorDetail.toLowerCase().includes('not found')
? 'not_found'
: errorDetail.toLowerCase().includes('expired')
? 'expired'
: errorDetail.toLowerCase().includes('fully used')
? 'used'
: errorDetail.toLowerCase().includes('already used')
? 'already_used_by_user'
: 'server_error'
setPromocodeError(t(`balance.promocode.errors.${errorKey}`))
} finally {
setPromocodeLoading(false)
}
}
return (
<div className="space-y-6">
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('balance.title')}</h1>
{/* Balance Card */}
<div className="card bg-gradient-to-br from-accent-500/10 to-transparent border-accent-500/20">
<div className="text-sm text-dark-400 mb-2">{t('balance.currentBalance')}</div>
<div className="text-4xl sm:text-5xl font-bold text-dark-50">
{formatAmount(balanceData?.balance_rubles || 0)}
<span className="text-2xl text-dark-400 ml-2">{currencySymbol}</span>
</div>
</div>
{/* Promo Code Section */}
<div className="card">
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('balance.promocode.title')}</h2>
<div className="flex gap-3">
<input
type="text"
value={promocode}
onChange={(e) => setPromocode(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handlePromocodeActivate()}
placeholder={t('balance.promocode.placeholder')}
className="input flex-1"
disabled={promocodeLoading}
/>
<button
onClick={handlePromocodeActivate}
disabled={!promocode.trim() || promocodeLoading}
className="btn-primary px-6 whitespace-nowrap"
>
{promocodeLoading ? t('balance.promocode.activating') : t('balance.promocode.activate')}
</button>
</div>
{promocodeError && (
<div className="mt-3 p-3 rounded-lg bg-error-500/10 border border-error-500/30 text-error-400 text-sm">
{promocodeError}
</div>
)}
{promocodeSuccess && (
<div className="mt-3 p-3 rounded-lg bg-success-500/10 border border-success-500/30 text-success-400 text-sm">
<div className="font-medium">{promocodeSuccess.message}</div>
{promocodeSuccess.amount > 0 && (
<div className="mt-1">{t('balance.promocode.balanceAdded', { amount: promocodeSuccess.amount.toFixed(2) })}</div>
)}
</div>
)}
</div>
{/* Payment Methods */}
{paymentMethods && paymentMethods.length > 0 && (
<div className="card">
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('balance.topUpBalance')}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{paymentMethods.map((method) => {
const methodKey = method.id.toLowerCase().replace(/-/g, '_')
const translatedName = t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' })
const translatedDesc = t(`balance.paymentMethods.${methodKey}.description`, { defaultValue: '' })
return (
<button
key={method.id}
disabled={!method.is_available}
onClick={() => method.is_available && setSelectedMethod(method)}
className={`p-4 rounded-xl border text-left transition-all ${
method.is_available
? 'border-dark-700/50 hover:border-accent-500/50 bg-dark-800/30 cursor-pointer'
: 'border-dark-800/30 bg-dark-900/30 opacity-50 cursor-not-allowed'
}`}
>
<div className="font-semibold text-dark-100">{translatedName || method.name}</div>
{(translatedDesc || method.description) && (
<div className="text-sm text-dark-500 mt-1">{translatedDesc || method.description}</div>
)}
<div className="text-xs text-dark-600 mt-3">
{formatAmount(method.min_amount_kopeks / 100, 0)} {formatAmount(method.max_amount_kopeks / 100, 0)} {currencySymbol}
</div>
</button>
)
})}
</div>
<p className="mt-4 text-sm text-dark-500">{t('balance.useBot')}</p>
</div>
)}
{/* Transaction History */}
<div className="card">
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('balance.transactionHistory')}</h2>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
) : transactions?.items && transactions.items.length > 0 ? (
<div className="space-y-3">
{transactions.items.map((tx) => (
<div
key={tx.id}
className="flex items-center justify-between p-4 rounded-xl bg-dark-800/30 border border-dark-700/30"
>
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<span className={getTypeBadge(tx.type)}>
{getTypeLabel(tx.type)}
</span>
<span className="text-xs text-dark-500">
{new Date(tx.created_at).toLocaleDateString()}
</span>
</div>
{tx.description && (
<div className="text-sm text-dark-400">{tx.description}</div>
)}
</div>
<div className={`text-lg font-semibold ${tx.amount_kopeks > 0 ? 'text-success-400' : 'text-error-400'}`}>
{tx.amount_kopeks > 0 ? '+' : ''}{formatAmount(tx.amount_rubles)} {currencySymbol}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
<svg className="w-8 h-8 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
</svg>
</div>
<div className="text-dark-400">{t('balance.noTransactions')}</div>
</div>
)}
{transactions && transactions.pages > 1 && (
<div className="mt-4 flex items-center gap-3 flex-wrap text-sm text-dark-500">
<button
type="button"
onClick={() => setTransactionsPage((prev) => Math.max(1, prev - 1))}
disabled={transactions.page <= 1}
className={`btn-secondary text-xs sm:text-sm flex-1 sm:flex-none min-w-[120px] ${
transactions.page <= 1 ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{t('common.back')}
</button>
<div className="flex-1 text-center">
{t('balance.page', { current: transactions.page, total: transactions.pages })}
</div>
<button
type="button"
onClick={() =>
setTransactionsPage((prev) =>
transactions.pages ? Math.min(transactions.pages, prev + 1) : prev + 1
)
}
disabled={transactions.page >= transactions.pages}
className={`btn-secondary text-xs sm:text-sm flex-1 sm:flex-none min-w-[120px] ${
transactions.page >= transactions.pages ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{t('common.next')}
</button>
</div>
)}
</div>
{/* TopUp Modal */}
{selectedMethod && (
<TopUpModal
method={selectedMethod}
onClose={() => setSelectedMethod(null)}
/>
)}
</div>
)
}

239
src/pages/Contests.tsx Normal file
View File

@@ -0,0 +1,239 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { contestsApi, ContestInfo, ContestGameData } from '../api/contests'
const GamepadIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 01-.657.643 48.39 48.39 0 01-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 01-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 00-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.039 48.039 0 01-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 00.657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 005.427-.63 48.05 48.05 0 00.582-4.717.532.532 0 00-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.959.401v0a.656.656 0 00.659-.663 47.703 47.703 0 00-.31-4.82.78.78 0 01.79-.869" />
</svg>
)
const TrophyIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 18.75h-9m9 0a3 3 0 013 3h-15a3 3 0 013-3m9 0v-3.375c0-.621-.503-1.125-1.125-1.125h-.871M7.5 18.75v-3.375c0-.621.504-1.125 1.125-1.125h.872m5.007 0H9.497m5.007 0a7.454 7.454 0 01-.982-3.172M9.497 14.25a7.454 7.454 0 00.981-3.172M5.25 4.236c-.982.143-1.954.317-2.916.52A6.003 6.003 0 007.73 9.728M5.25 4.236V4.5c0 2.108.966 3.99 2.48 5.228M5.25 4.236V2.721C7.456 2.41 9.71 2.25 12 2.25c2.291 0 4.545.16 6.75.47v1.516M7.73 9.728a6.726 6.726 0 002.748 1.35m8.272-6.842V4.5c0 2.108-.966 3.99-2.48 5.228m2.48-5.492a46.32 46.32 0 012.916.52 6.003 6.003 0 01-5.395 4.972m0 0a6.726 6.726 0 01-2.749 1.35m0 0a6.772 6.772 0 01-3.044 0" />
</svg>
)
export default function Contests() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [selectedContest, setSelectedContest] = useState<ContestInfo | null>(null)
const [gameData, setGameData] = useState<ContestGameData | null>(null)
const [result, setResult] = useState<{ is_winner: boolean; message: string } | null>(null)
const { data: contests, isLoading, error } = useQuery({
queryKey: ['contests'],
queryFn: contestsApi.getContests,
})
const getGameMutation = useMutation({
mutationFn: contestsApi.getContestGame,
onSuccess: (data) => {
setGameData(data)
setResult(null)
},
})
const submitAnswerMutation = useMutation({
mutationFn: ({ roundId, answer }: { roundId: number; answer: string }) =>
contestsApi.submitAnswer(roundId, answer),
onSuccess: (data) => {
setResult(data)
queryClient.invalidateQueries({ queryKey: ['contests'] })
},
})
const handlePlayContest = async (contest: ContestInfo) => {
setSelectedContest(contest)
getGameMutation.mutate(contest.id)
}
const handleSubmitAnswer = (answer: string) => {
if (gameData) {
submitAnswerMutation.mutate({ roundId: gameData.round_id, answer })
}
}
const handleCloseGame = () => {
setSelectedContest(null)
setGameData(null)
setResult(null)
}
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-64">
<div className="w-10 h-10 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (error) {
return (
<div className="card bg-red-500/10 border-red-500/20">
<p className="text-red-400">{t('contests.error')}</p>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<GamepadIcon />
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('contests.title')}</h1>
</div>
{/* Game Modal */}
{selectedContest && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60">
<div className="card max-w-lg w-full max-h-[80vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">{selectedContest.name}</h2>
<button onClick={handleCloseGame} className="text-dark-400 hover:text-dark-200">
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{getGameMutation.isPending && (
<div className="flex justify-center py-8">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)}
{result && (
<div className={`p-4 rounded-lg mb-4 ${result.is_winner ? 'bg-success-500/20 text-success-400' : 'bg-red-500/20 text-red-400'}`}>
<p className="font-medium">{result.message}</p>
</div>
)}
{gameData && !result && (
<div className="space-y-4">
<p className="text-dark-300">{gameData.instructions}</p>
{/* Render game based on type */}
{(gameData.game_type === 'quest' || gameData.game_type === 'locks') && (
<div className="grid grid-cols-5 gap-2">
{Array.from({ length: gameData.game_data.total || gameData.game_data.grid_size || 9 }).map((_, i) => (
<button
key={i}
onClick={() => handleSubmitAnswer(`${i}_${gameData.game_data.secret}`)}
disabled={submitAnswerMutation.isPending}
className="aspect-square bg-dark-700 hover:bg-dark-600 rounded-lg flex items-center justify-center text-2xl transition-colors"
>
{gameData.game_type === 'locks' ? '🔒' : '🎛'}
</button>
))}
</div>
)}
{gameData.game_type === 'server' && (
<div className="grid grid-cols-5 gap-2">
{gameData.game_data.flags?.map((flag: string, i: number) => (
<button
key={i}
onClick={() => handleSubmitAnswer(flag)}
disabled={submitAnswerMutation.isPending}
className="p-3 bg-dark-700 hover:bg-dark-600 rounded-lg text-2xl transition-colors"
>
{flag}
</button>
))}
</div>
)}
{gameData.game_type === 'blitz' && (
<button
onClick={() => handleSubmitAnswer('blitz')}
disabled={submitAnswerMutation.isPending}
className="w-full py-4 bg-accent-500 hover:bg-accent-600 rounded-lg font-bold text-lg transition-colors"
>
{gameData.game_data.button_text || t('contests.imHere')}
</button>
)}
{['cipher', 'emoji', 'anagram'].includes(gameData.game_type) && (
<form
onSubmit={(e) => {
e.preventDefault()
const input = e.currentTarget.elements.namedItem('answer') as HTMLInputElement
handleSubmitAnswer(input.value)
}}
className="space-y-3"
>
<div className="text-center text-2xl font-mono bg-dark-700 p-4 rounded-lg">
{gameData.game_data.question || gameData.game_data.letters}
</div>
<input
name="answer"
type="text"
placeholder={t('contests.enterAnswer')}
className="w-full px-4 py-3 bg-dark-700 border border-dark-600 rounded-lg focus:border-accent-500 focus:outline-none"
/>
<button
type="submit"
disabled={submitAnswerMutation.isPending}
className="w-full btn-primary"
>
{t('contests.submit')}
</button>
</form>
)}
</div>
)}
{result && (
<button onClick={handleCloseGame} className="w-full btn-secondary mt-4">
{t('common.close')}
</button>
)}
</div>
</div>
)}
{/* Contests List */}
{contests && contests.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2">
{contests.map((contest) => (
<div key={contest.id} className="card">
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold text-lg">{contest.name}</h3>
{contest.description && (
<p className="text-dark-400 text-sm mt-1">{contest.description}</p>
)}
</div>
<div className="flex items-center gap-1 text-accent-400">
<TrophyIcon />
<span className="text-sm font-medium">+{contest.prize_days} {t('contests.days')}</span>
</div>
</div>
<div className="mt-4">
{contest.already_played ? (
<button disabled className="w-full btn-secondary opacity-50 cursor-not-allowed">
{t('contests.alreadyPlayed')}
</button>
) : (
<button
onClick={() => handlePlayContest(contest)}
className="w-full btn-primary"
>
{t('contests.play')}
</button>
)}
</div>
</div>
))}
</div>
) : (
<div className="card text-center py-12">
<GamepadIcon />
<p className="text-dark-400 mt-4">{t('contests.noContests')}</p>
</div>
)}
</div>
)
}

460
src/pages/Dashboard.tsx Normal file
View File

@@ -0,0 +1,460 @@
import { useState, useEffect, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { DotLottieReact } from '@lottiefiles/dotlottie-react'
import { useAuthStore } from '../store/auth'
import { subscriptionApi } from '../api/subscription'
import { referralApi } from '../api/referral'
import { balanceApi } from '../api/balance'
import { wheelApi } from '../api/wheel'
import ConnectionModal from '../components/ConnectionModal'
import Onboarding, { useOnboarding } from '../components/Onboarding'
import { useCurrency } from '../hooks/useCurrency'
// Icons
const ArrowRightIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
)
const SparklesIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
</svg>
)
const ChevronRightIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
)
const SupportLottieIcon = () => (
<div className="w-6 h-6">
<DotLottieReact
src="https://lottie.host/51d2c570-0aa0-47af-a8cb-2bd4afe8d6ae/RzpYZ5zUKX.lottie"
loop
autoplay
/>
</div>
)
export default function Dashboard() {
const { t } = useTranslation()
const { user, refreshUser } = useAuthStore()
const queryClient = useQueryClient()
const { formatAmount, currencySymbol, formatPositive } = useCurrency()
const [trialError, setTrialError] = useState<string | null>(null)
const [showConnectionModal, setShowConnectionModal] = useState(false)
const { isCompleted: isOnboardingCompleted, complete: completeOnboarding } = useOnboarding()
const [showOnboarding, setShowOnboarding] = useState(false)
// Refresh user data on mount
useEffect(() => {
refreshUser()
}, [])
// Fetch balance from API with no caching
const { data: balanceData } = useQuery({
queryKey: ['balance'],
queryFn: balanceApi.getBalance,
staleTime: 0,
refetchOnMount: 'always',
})
const { data: subscription, isLoading: subLoading, error: subError } = useQuery({
queryKey: ['subscription'],
queryFn: subscriptionApi.getSubscription,
retry: false,
staleTime: 0,
refetchOnMount: 'always',
})
const { data: trialInfo, isLoading: trialLoading } = useQuery({
queryKey: ['trial-info'],
queryFn: subscriptionApi.getTrialInfo,
enabled: !subscription && !subLoading,
})
const { data: referralInfo, isLoading: refLoading } = useQuery({
queryKey: ['referral-info'],
queryFn: referralApi.getReferralInfo,
})
// Fetch wheel config to show banner if enabled
const { data: wheelConfig } = useQuery({
queryKey: ['wheel-config'],
queryFn: wheelApi.getConfig,
staleTime: 60000, // 1 minute
retry: false,
})
const activateTrialMutation = useMutation({
mutationFn: subscriptionApi.activateTrial,
onSuccess: () => {
setTrialError(null)
queryClient.invalidateQueries({ queryKey: ['subscription'] })
queryClient.invalidateQueries({ queryKey: ['trial-info'] })
refreshUser()
},
onError: (error: { response?: { data?: { detail?: string } } }) => {
setTrialError(error.response?.data?.detail || t('common.error'))
},
})
const hasNoSubscription = !subscription && !subLoading && subError
// Show onboarding for new users after data loads
useEffect(() => {
if (!isOnboardingCompleted && !subLoading && !refLoading) {
// Small delay to ensure DOM is ready
const timer = setTimeout(() => setShowOnboarding(true), 500)
return () => clearTimeout(timer)
}
}, [isOnboardingCompleted, subLoading, refLoading])
// Define onboarding steps based on available data
const onboardingSteps = useMemo(() => {
type Placement = 'top' | 'bottom' | 'left' | 'right'
const steps: Array<{
target: string
title: string
description: string
placement: Placement
}> = [
{
target: 'welcome',
title: t('onboarding.steps.welcome.title'),
description: t('onboarding.steps.welcome.description'),
placement: 'bottom',
},
{
target: 'balance',
title: t('onboarding.steps.balance.title'),
description: t('onboarding.steps.balance.description'),
placement: 'bottom',
},
{
target: 'subscription-status',
title: t('onboarding.steps.subscription.title'),
description: t('onboarding.steps.subscription.description'),
placement: 'bottom',
},
]
// Add connect devices step only if subscription exists
if (subscription?.subscription_url) {
steps.splice(1, 0, {
target: 'connect-devices',
title: t('onboarding.steps.connectDevices.title'),
description: t('onboarding.steps.connectDevices.description'),
placement: 'bottom',
})
}
steps.push({
target: 'quick-actions',
title: t('onboarding.steps.quickActions.title'),
description: t('onboarding.steps.quickActions.description'),
placement: 'top',
})
return steps
}, [t, subscription])
const handleOnboardingComplete = () => {
setShowOnboarding(false)
completeOnboarding()
}
// Calculate traffic percentage color
const getTrafficColor = (percent: number) => {
if (percent > 90) return 'bg-error-500'
if (percent > 70) return 'bg-warning-500'
return 'bg-success-500'
}
return (
<div className="space-y-6">
{/* Header */}
<div data-onboarding="welcome">
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">
{t('dashboard.welcome', { name: user?.first_name || user?.username || '' })}
</h1>
<p className="text-dark-400 mt-1">{t('dashboard.yourSubscription')}</p>
</div>
{/* Subscription Status - Main Card */}
{subscription && (
<div className="card">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-dark-100">{t('subscription.status')}</h2>
<span className={subscription.is_active ? 'badge-success' : 'badge-error'}>
{subscription.is_active ? t('subscription.active') : t('subscription.expired')}
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div>
<div className="text-sm text-dark-500 mb-1">{t('subscription.expiresAt')}</div>
<div className="text-dark-100 font-medium">
{new Date(subscription.end_date).toLocaleDateString()}
</div>
</div>
<div>
<div className="text-sm text-dark-500 mb-1">{t('subscription.traffic')}</div>
<div className="text-dark-100 font-medium">
{subscription.traffic_used_gb.toFixed(1)} / {subscription.traffic_limit_gb || '∞'} GB
</div>
</div>
<div>
<div className="text-sm text-dark-500 mb-1">{t('subscription.devices')}</div>
<div className="text-dark-100 font-medium">{subscription.device_limit}</div>
</div>
<div>
<div className="text-sm text-dark-500 mb-1">{t('subscription.timeLeft')}</div>
<div className="text-dark-100 font-medium">
{subscription.days_left > 0
? `${subscription.days_left} ${t('subscription.days')}`
: `${subscription.hours_left}${t('subscription.hours')} ${subscription.minutes_left}${t('subscription.minutes')}`
}
</div>
</div>
</div>
{/* Traffic Progress */}
{subscription.traffic_limit_gb > 0 && (
<div className="mt-6">
<div className="flex justify-between text-sm mb-2">
<span className="text-dark-400">{t('subscription.trafficUsed')}</span>
<span className="text-dark-300">{subscription.traffic_used_percent.toFixed(1)}%</span>
</div>
<div className="progress-bar">
<div
className={`progress-fill ${getTrafficColor(subscription.traffic_used_percent)}`}
style={{ width: `${Math.min(subscription.traffic_used_percent, 100)}%` }}
/>
</div>
</div>
)}
<div className={`mt-6 grid gap-3 ${subscription.subscription_url ? 'grid-cols-2' : 'grid-cols-1'}`}>
<Link to="/subscription" className="btn-primary text-center text-sm py-2.5">
{t('dashboard.viewSubscription')}
</Link>
{subscription.subscription_url && (
<button
onClick={() => setShowConnectionModal(true)}
className="btn-secondary text-sm py-2.5"
data-onboarding="connect-devices"
>
{t('subscription.getConfig')}
</button>
)}
</div>
</div>
)}
{/* Stats Grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{/* Balance */}
<Link to="/balance" className="card-hover group" data-onboarding="balance">
<div className="flex items-center justify-between mb-3">
<span className="text-dark-400 text-sm">{t('balance.currentBalance')}</span>
<span className="text-dark-600 group-hover:text-accent-400 transition-colors">
<ArrowRightIcon />
</span>
</div>
<div className="stat-value text-accent-400">
{formatAmount(balanceData?.balance_rubles || 0)}
<span className="text-lg ml-1 text-dark-400">{currencySymbol}</span>
</div>
</Link>
{/* Subscription */}
<Link to="/subscription" className="card-hover group" data-onboarding="subscription-status">
<div className="flex items-center justify-between mb-3">
<span className="text-dark-400 text-sm">{t('subscription.title')}</span>
<span className="text-dark-600 group-hover:text-accent-400 transition-colors">
<ArrowRightIcon />
</span>
</div>
{subLoading ? (
<div className="skeleton h-8 w-24" />
) : subscription ? (
<div className="stat-value">
{subscription.days_left > 0 ? (
<>
{subscription.days_left}
<span className="text-lg ml-1 text-dark-400">{t('subscription.days')}</span>
</>
) : subscription.hours_left > 0 ? (
<>
{subscription.hours_left}
<span className="text-lg ml-1 text-dark-400">{t('subscription.hours')}</span>
</>
) : subscription.minutes_left > 0 ? (
<>
{subscription.minutes_left}
<span className="text-lg ml-1 text-dark-400">{t('subscription.minutes')}</span>
</>
) : (
<span className="text-error-400">{t('subscription.expired')}</span>
)}
</div>
) : (
<div className="stat-value text-error-400">{t('subscription.inactive')}</div>
)}
</Link>
{/* Referrals */}
<Link to="/referral" className="card-hover group">
<div className="flex items-center justify-between mb-3">
<span className="text-dark-400 text-sm">{t('referral.stats.totalReferrals')}</span>
<span className="text-dark-600 group-hover:text-accent-400 transition-colors">
<ArrowRightIcon />
</span>
</div>
{refLoading ? (
<div className="skeleton h-8 w-16" />
) : (
<div className="stat-value">{referralInfo?.total_referrals || 0}</div>
)}
</Link>
{/* Earnings */}
<Link to="/referral" className="card-hover group">
<div className="flex items-center justify-between mb-3">
<span className="text-dark-400 text-sm">{t('referral.stats.totalEarnings')}</span>
<span className="text-dark-600 group-hover:text-accent-400 transition-colors">
<ArrowRightIcon />
</span>
</div>
{refLoading ? (
<div className="skeleton h-8 w-20" />
) : (
<div className="stat-value text-success-400">
{formatPositive(referralInfo?.total_earnings_rubles || 0)}
</div>
)}
</Link>
</div>
{/* Trial Activation */}
{hasNoSubscription && !trialLoading && trialInfo?.is_available && (
<div className="card border-accent-500/30 bg-gradient-to-br from-accent-500/5 to-transparent">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-accent-500/20 flex items-center justify-center flex-shrink-0">
<SparklesIcon />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-dark-100 mb-2">
{t('subscription.trial.title', 'Free Trial')}
</h3>
<p className="text-dark-400 text-sm mb-4">
{t('subscription.trial.description', 'Try our VPN service for free!')}
</p>
<div className="flex gap-6 mb-6">
<div className="text-center">
<div className="text-2xl font-bold text-accent-400">{trialInfo.duration_days}</div>
<div className="text-xs text-dark-500">{t('subscription.trial.days', 'days')}</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-accent-400">{trialInfo.traffic_limit_gb}</div>
<div className="text-xs text-dark-500">GB</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-accent-400">{trialInfo.device_limit}</div>
<div className="text-xs text-dark-500">{t('subscription.trial.devices', 'devices')}</div>
</div>
</div>
{trialInfo.requires_payment && trialInfo.price_rubles > 0 && (
<p className="text-sm text-dark-400 mb-4">
{t('subscription.trial.price', 'Activation price')}: {trialInfo.price_rubles.toFixed(2)}
</p>
)}
{trialError && (
<div className="bg-error-500/10 border border-error-500/30 text-error-400 text-sm p-3 rounded-xl mb-4">
{trialError}
</div>
)}
<button
onClick={() => activateTrialMutation.mutate()}
disabled={activateTrialMutation.isPending}
className="btn-primary"
>
{activateTrialMutation.isPending
? t('common.loading', 'Loading...')
: t('subscription.trial.activate', 'Activate Free Trial')}
</button>
</div>
</div>
</div>
)}
{/* Fortune Wheel Banner */}
{wheelConfig?.is_enabled && (
<Link
to="/wheel"
className="group card-hover flex items-center justify-between"
>
<div className="flex items-center gap-4">
{/* Emoji */}
<span className="text-3xl">🎰</span>
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-dark-100">
{t('wheel.banner.title')}
</h3>
<p className="text-dark-400 text-sm">
{t('wheel.banner.description')}
</p>
</div>
</div>
<div className="text-dark-500 group-hover:text-accent-400 group-hover:translate-x-1 transition-all duration-300 flex-shrink-0">
<ChevronRightIcon />
</div>
</Link>
)}
{/* Quick Actions */}
<div className="card" data-onboarding="quick-actions">
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('dashboard.quickActions')}</h3>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Link to="/balance" className="btn-secondary justify-center text-center text-sm py-2.5">
{t('dashboard.topUpBalance')}
</Link>
<Link to="/subscription" state={{ scrollToExtend: true }} className="btn-secondary justify-center text-center text-sm py-2.5">
{t('subscription.renew')}
</Link>
<Link to="/referral" className="btn-secondary justify-center text-center text-sm py-2.5">
{t('dashboard.inviteFriends')}
</Link>
<Link to="/support" className="btn-secondary justify-center text-center text-sm py-2.5 flex items-center gap-2">
<SupportLottieIcon />
<span>{t('dashboard.getSupport')}</span>
</Link>
</div>
</div>
{/* Connection Modal */}
{showConnectionModal && (
<ConnectionModal onClose={() => setShowConnectionModal(false)} />
)}
{/* Onboarding Tutorial */}
{showOnboarding && (
<Onboarding
steps={onboardingSteps}
onComplete={handleOnboardingComplete}
onSkip={handleOnboardingComplete}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,315 @@
import { useEffect, useState, useCallback } from 'react'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query'
import { brandingApi } from '../api/branding'
type Status = 'countdown' | 'fallback' | 'error'
// App schemes configuration - same as miniapp
const appSchemes = [
{ scheme: 'happ://', icon: 'H', name: 'Happ' },
{ scheme: 'flclash://', icon: 'F', name: 'FlClash' },
{ scheme: 'clash://', icon: 'C', name: 'Clash Meta' },
{ scheme: 'sing-box://', icon: 'S', name: 'sing-box' },
{ scheme: 'v2rayng://', icon: 'V', name: 'v2rayNG' },
{ scheme: 'sub://', icon: 'R', name: 'Shadowrocket' },
{ scheme: 'shadowrocket://', icon: 'R', name: 'Shadowrocket' },
{ scheme: 'hiddify://', icon: 'H', name: 'Hiddify' },
{ scheme: 'streisand://', icon: 'S', name: 'Streisand' },
{ scheme: 'quantumult://', icon: 'Q', name: 'Quantumult X' },
{ scheme: 'surge://', icon: 'S', name: 'Surge' },
{ scheme: 'loon://', icon: 'L', name: 'Loon' },
{ scheme: 'nekobox://', icon: 'N', name: 'NekoBox' },
{ scheme: 'v2box://', icon: 'V', name: 'V2Box' },
]
const COUNTDOWN_SECONDS = 5
export default function DeepLinkRedirect() {
const { i18n } = useTranslation()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const [status, setStatus] = useState<Status>('countdown')
const [countdown, setCountdown] = useState(COUNTDOWN_SECONDS)
const [copied, setCopied] = useState(false)
// Get branding
const { data: branding } = useQuery({
queryKey: ['branding'],
queryFn: brandingApi.getBranding,
staleTime: 60000,
})
const projectName = branding ? branding.name : (import.meta.env.VITE_APP_NAME || 'VPN')
const logoLetter = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V'
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null
// Get parameters
const deepLink = searchParams.get('url') || searchParams.get('deeplink') || ''
const subscriptionUrl = searchParams.get('sub') || ''
const appParam = searchParams.get('app') || ''
// Detect app from deep link
const appInfo = deepLink
? appSchemes.find(a => deepLink.toLowerCase().startsWith(a.scheme))
: null
const appName = appInfo?.name || appParam || 'VPN'
const appIcon = appInfo?.icon || appName[0]?.toUpperCase() || 'V'
// Translations
const texts = {
en: {
connecting: 'Connecting to',
redirecting: 'Redirecting in',
seconds: 'seconds',
manual: 'If nothing happens, click the button below.',
openApp: 'Open App',
copyLink: 'Copy subscription link',
copied: 'Copied!',
tryAgain: 'Try again',
backToCabinet: 'Back to cabinet',
errorTitle: 'Error',
errorDesc: 'Connection link is missing',
goToSubscription: 'Go to subscription',
howToAdd: 'How to add manually:',
step1: 'Copy the link using the button above',
step2: 'Open the app',
step3: 'Find "+" or "Add subscription"',
step4: 'Select "From clipboard" or "Paste link"',
},
ru: {
connecting: 'Подключение к',
redirecting: 'Перенаправление через',
seconds: 'сек',
manual: 'Если ничего не происходит, нажмите кнопку ниже.',
openApp: 'Открыть приложение',
copyLink: 'Скопировать ссылку подписки',
copied: 'Скопировано!',
tryAgain: 'Попробовать снова',
backToCabinet: 'Вернуться в кабинет',
errorTitle: 'Ошибка',
errorDesc: 'Ссылка для подключения не найдена',
goToSubscription: 'Перейти к подписке',
howToAdd: 'Как добавить вручную:',
step1: 'Скопируйте ссылку кнопкой выше',
step2: 'Откройте приложение',
step3: 'Найдите "+" или "Добавить подписку"',
step4: 'Выберите "Из буфера" или "Вставить ссылку"',
}
}
const lang = i18n.language?.startsWith('ru') ? 'ru' : 'en'
const txt = texts[lang]
// Open deep link - same as miniapp, just window.location.href
const openDeepLink = useCallback(() => {
if (!deepLink) return
window.location.href = deepLink
}, [deepLink])
// Countdown timer effect
useEffect(() => {
if (!deepLink) {
setStatus('error')
return
}
if (status !== 'countdown') return
const timer = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
clearInterval(timer)
openDeepLink()
// Show fallback after a delay
setTimeout(() => setStatus('fallback'), 2000)
return 0
}
return prev - 1
})
}, 1000)
return () => clearInterval(timer)
}, [deepLink, status, openDeepLink])
const handleCopyLink = async () => {
const linkToCopy = subscriptionUrl || deepLink
try {
await navigator.clipboard.writeText(linkToCopy)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
const textarea = document.createElement('textarea')
textarea.value = linkToCopy
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
// Progress percentage
const progress = ((COUNTDOWN_SECONDS - countdown) / COUNTDOWN_SECONDS) * 100
return (
<div className="min-h-screen flex items-center justify-center p-4">
{/* Background */}
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
<div className="fixed inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-accent-500/10 via-transparent to-transparent" />
<div className="relative text-center max-w-sm w-full">
{/* Logo with pulse animation */}
<div className="mx-auto w-20 h-20 rounded-2xl bg-gradient-to-br from-accent-400 to-accent-600 flex items-center justify-center mb-6 shadow-lg shadow-accent-500/30 overflow-hidden animate-pulse">
{branding?.has_custom_logo && logoUrl ? (
<img src={logoUrl} alt={projectName || 'Logo'} className="w-full h-full object-cover" />
) : (
<span className="text-white font-bold text-3xl">{logoLetter}</span>
)}
</div>
<h1 className="text-2xl font-bold text-dark-50 mb-1">{projectName || 'VPN'}</h1>
{status !== 'error' && (
<p className="text-dark-400 mb-6">{txt.connecting} {appName}...</p>
)}
{/* Countdown State */}
{status === 'countdown' && (
<div className="card !bg-dark-800/80 backdrop-blur-sm p-6">
{/* App icon */}
<div className="w-16 h-16 rounded-2xl bg-accent-500/20 flex items-center justify-center mx-auto mb-4">
<span className="text-2xl font-bold text-accent-400">{appIcon}</span>
</div>
{/* Spinner */}
<div className="w-12 h-12 border-3 border-dark-700 border-t-accent-500 rounded-full animate-spin mx-auto mb-4" />
{/* Timer */}
<div className="mb-4">
<p className="text-sm text-dark-500 mb-2">{txt.redirecting}</p>
<div className="flex items-center justify-center gap-2">
<span className="text-4xl font-bold text-accent-400">{countdown}</span>
<span className="text-dark-400">{txt.seconds}</span>
</div>
</div>
{/* Progress bar */}
<div className="w-full h-1.5 bg-dark-700 rounded-full overflow-hidden mb-4">
<div
className="h-full bg-gradient-to-r from-accent-400 to-accent-600 rounded-full transition-all duration-1000 ease-linear"
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-sm text-dark-500 mb-4">{txt.manual}</p>
{/* Open now button */}
<button
onClick={openDeepLink}
className="btn-primary w-full py-3 flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
{txt.openApp}
</button>
</div>
)}
{/* Fallback State - App didn't open */}
{status === 'fallback' && (
<div className="card !bg-dark-800/80 backdrop-blur-sm p-6">
{/* App icon */}
<div className="w-16 h-16 rounded-2xl bg-accent-500/20 flex items-center justify-center mx-auto mb-4">
<span className="text-2xl font-bold text-accent-400">{appIcon}</span>
</div>
<div className="space-y-3">
{/* Copy subscription link */}
<button
onClick={handleCopyLink}
className="btn-primary w-full py-3 flex items-center justify-center gap-2"
>
{copied ? (
<>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
{txt.copied}
</>
) : (
<>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
{txt.copyLink}
</>
)}
</button>
{/* Try again button */}
<button
onClick={openDeepLink}
className="btn-secondary w-full flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
{txt.tryAgain}
</button>
{/* Back to cabinet */}
<button
onClick={() => navigate('/subscription')}
className="w-full text-sm text-dark-500 hover:text-dark-300 transition-colors py-2"
>
{txt.backToCabinet}
</button>
</div>
{/* Instructions */}
<div className="mt-6 p-4 rounded-xl bg-dark-900/50 border border-dark-700 text-left">
<h3 className="text-sm font-medium text-dark-200 mb-2">{txt.howToAdd}</h3>
<ol className="text-xs text-dark-400 space-y-1.5 list-decimal list-inside">
<li>{txt.step1}</li>
<li>{txt.step2} {appName}</li>
<li>{txt.step3}</li>
<li>{txt.step4}</li>
</ol>
</div>
</div>
)}
{/* Error State */}
{status === 'error' && (
<div className="card !bg-dark-800/80 backdrop-blur-sm p-6">
<div className="w-16 h-16 rounded-full bg-error-500/20 flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-error-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
</div>
<p className="text-dark-200 font-medium mb-2">{txt.errorTitle}</p>
<p className="text-sm text-dark-400 mb-6">{txt.errorDesc}</p>
<button
onClick={() => navigate('/subscription')}
className="btn-primary w-full"
>
{txt.goToSubscription}
</button>
</div>
)}
{/* Footer */}
<div className="mt-8 flex items-center justify-center gap-2 text-dark-600">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
</svg>
<span className="text-xs">VPN Config Redirect</span>
</div>
</div>
</div>
)
}

295
src/pages/Info.tsx Normal file
View File

@@ -0,0 +1,295 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { infoApi, FaqPage } from '../api/info'
const InfoIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
)
const QuestionIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
</svg>
)
const DocumentIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
)
const ShieldIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
)
const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
<svg
className={`w-5 h-5 transition-transform ${expanded ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
)
type TabType = 'faq' | 'rules' | 'privacy' | 'offer'
// Convert plain text to HTML with proper formatting
const formatContent = (content: string): string => {
if (!content) return ''
// Check if content already has HTML tags
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(content)
if (hasHtmlTags) {
return content
}
// Convert plain text to formatted HTML
return content
.split(/\n\n+/) // Split by double newlines (paragraphs)
.map(paragraph => {
// Check if it's a header (starts with # or numeric like "1.")
if (/^#{1,4}\s/.test(paragraph)) {
const level = paragraph.match(/^(#{1,4})/)?.[1].length || 1
const text = paragraph.replace(/^#{1,4}\s*/, '')
return `<h${level}>${text}</h${level}>`
}
// Check for list items
if (/^[-•]\s/.test(paragraph) || /^\d+[.)]\s/.test(paragraph)) {
const lines = paragraph.split('\n')
const isOrdered = /^\d+[.)]\s/.test(lines[0])
const listItems = lines
.map(line => line.replace(/^[-•]\s*/, '').replace(/^\d+[.)]\s*/, ''))
.filter(line => line.trim())
.map(line => `<li>${line}</li>`)
.join('')
return isOrdered ? `<ol>${listItems}</ol>` : `<ul>${listItems}</ul>`
}
// Regular paragraph - handle single line breaks within
const formattedParagraph = paragraph
.split('\n')
.join('<br/>')
return `<p>${formattedParagraph}</p>`
})
.join('')
}
export default function Info() {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState<TabType>('faq')
const [expandedFaq, setExpandedFaq] = useState<number | null>(null)
const { data: faqPages, isLoading: faqLoading } = useQuery({
queryKey: ['faq-pages'],
queryFn: infoApi.getFaqPages,
enabled: activeTab === 'faq',
staleTime: 0,
refetchOnMount: 'always',
})
const { data: rules, isLoading: rulesLoading } = useQuery({
queryKey: ['rules'],
queryFn: infoApi.getRules,
enabled: activeTab === 'rules',
staleTime: 0,
refetchOnMount: 'always',
})
const { data: privacy, isLoading: privacyLoading } = useQuery({
queryKey: ['privacy-policy'],
queryFn: infoApi.getPrivacyPolicy,
enabled: activeTab === 'privacy',
staleTime: 0,
refetchOnMount: 'always',
})
const { data: offer, isLoading: offerLoading } = useQuery({
queryKey: ['public-offer'],
queryFn: infoApi.getPublicOffer,
enabled: activeTab === 'offer',
staleTime: 0,
refetchOnMount: 'always',
})
const tabs = [
{ id: 'faq' as TabType, label: t('info.faq'), icon: QuestionIcon },
{ id: 'rules' as TabType, label: t('info.rules'), icon: DocumentIcon },
{ id: 'privacy' as TabType, label: t('info.privacy'), icon: ShieldIcon },
{ id: 'offer' as TabType, label: t('info.offer'), icon: DocumentIcon },
]
const toggleFaq = (id: number) => {
setExpandedFaq(expandedFaq === id ? null : id)
}
const renderContent = () => {
if (activeTab === 'faq') {
if (faqLoading) {
return (
<div className="flex justify-center py-8">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (!faqPages || faqPages.length === 0) {
return (
<div className="text-center py-8 text-dark-400">
{t('info.noFaq')}
</div>
)
}
return (
<div className="space-y-2">
{faqPages.map((faq: FaqPage) => (
<div key={faq.id} className="card p-0 overflow-hidden">
<button
onClick={() => toggleFaq(faq.id)}
className="w-full px-4 py-3 flex items-center justify-between text-left hover:bg-dark-800/50 transition-colors"
>
<span className="font-medium">{faq.title}</span>
<ChevronIcon expanded={expandedFaq === faq.id} />
</button>
{expandedFaq === faq.id && (
<div className="px-4 pb-4 text-dark-300 prose prose-invert max-w-none">
<div dangerouslySetInnerHTML={{ __html: formatContent(faq.content) }} />
</div>
)}
</div>
))}
</div>
)
}
if (activeTab === 'rules') {
if (rulesLoading) {
return (
<div className="flex justify-center py-8">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (!rules?.content) {
return (
<div className="text-center py-8 text-dark-400">
{t('info.noContent')}
</div>
)
}
return (
<div className="card prose prose-invert max-w-none">
<div dangerouslySetInnerHTML={{ __html: formatContent(rules.content) }} />
{rules.updated_at && (
<p className="text-sm text-dark-400 mt-6 pt-4 border-t border-dark-700">
{t('info.updatedAt')}: {new Date(rules.updated_at).toLocaleDateString()}
</p>
)}
</div>
)
}
if (activeTab === 'privacy') {
if (privacyLoading) {
return (
<div className="flex justify-center py-8">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (!privacy?.content) {
return (
<div className="text-center py-8 text-dark-400">
{t('info.noContent')}
</div>
)
}
return (
<div className="card prose prose-invert max-w-none">
<div dangerouslySetInnerHTML={{ __html: formatContent(privacy.content) }} />
{privacy.updated_at && (
<p className="text-sm text-dark-400 mt-6 pt-4 border-t border-dark-700">
{t('info.updatedAt')}: {new Date(privacy.updated_at).toLocaleDateString()}
</p>
)}
</div>
)
}
if (activeTab === 'offer') {
if (offerLoading) {
return (
<div className="flex justify-center py-8">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (!offer?.content) {
return (
<div className="text-center py-8 text-dark-400">
{t('info.noContent')}
</div>
)
}
return (
<div className="card prose prose-invert max-w-none">
<div dangerouslySetInnerHTML={{ __html: formatContent(offer.content) }} />
{offer.updated_at && (
<p className="text-sm text-dark-400 mt-6 pt-4 border-t border-dark-700">
{t('info.updatedAt')}: {new Date(offer.updated_at).toLocaleDateString()}
</p>
)}
</div>
)
}
return null
}
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<InfoIcon />
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('info.title')}</h1>
</div>
{/* Tabs */}
<div className="flex flex-wrap gap-2">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeTab === tab.id
? 'bg-accent-500 text-white'
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
}`}
>
<tab.icon />
{tab.label}
</button>
))}
</div>
{/* Content */}
{renderContent()}
</div>
)
}

267
src/pages/Login.tsx Normal file
View File

@@ -0,0 +1,267 @@
import { useState, useEffect, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query'
import { useAuthStore } from '../store/auth'
import { brandingApi, type BrandingInfo } from '../api/branding'
import LanguageSwitcher from '../components/LanguageSwitcher'
import TelegramLoginButton from '../components/TelegramLoginButton'
const BRANDING_CACHE_KEY = 'cabinet-branding-cache'
const BRANDING_CACHE_TTL = 1000 * 60 * 60 // 1 hour
const getCachedBranding = (): BrandingInfo | undefined => {
if (typeof window === 'undefined') {
return undefined
}
try {
const raw = localStorage.getItem(BRANDING_CACHE_KEY)
if (!raw) return undefined
const parsed = JSON.parse(raw) as { data?: BrandingInfo; timestamp?: number }
if (!parsed?.data || !parsed.timestamp) return undefined
if (Date.now() - parsed.timestamp > BRANDING_CACHE_TTL) {
localStorage.removeItem(BRANDING_CACHE_KEY)
return undefined
}
return parsed.data
} catch {
return undefined
}
}
const cacheBranding = (data: BrandingInfo) => {
if (typeof window === 'undefined') {
return
}
try {
localStorage.setItem(
BRANDING_CACHE_KEY,
JSON.stringify({ data, timestamp: Date.now() })
)
} catch {
// Ignore storage errors (e.g., private mode)
}
}
export default function Login() {
const { t } = useTranslation()
const navigate = useNavigate()
const { isAuthenticated, loginWithTelegram, loginWithEmail } = useAuthStore()
const [activeTab, setActiveTab] = useState<'telegram' | 'email'>('telegram')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isTelegramWebApp, setIsTelegramWebApp] = useState(false)
// Fetch branding
const cachedBranding = useMemo(() => getCachedBranding(), [])
const { data: branding } = useQuery<BrandingInfo>({
queryKey: ['branding'],
queryFn: brandingApi.getBranding,
staleTime: 60000,
placeholderData: cachedBranding,
})
useEffect(() => {
if (branding) {
cacheBranding(branding)
}
}, [branding])
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || ''
const appName = branding ? branding.name : (import.meta.env.VITE_APP_NAME || 'VPN')
const appLogo = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V'
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null
// Set document title
useEffect(() => {
document.title = appName || 'VPN'
}, [appName])
useEffect(() => {
if (isAuthenticated) {
navigate('/')
}
}, [isAuthenticated, navigate])
// Try Telegram WebApp authentication on mount
useEffect(() => {
const tryTelegramAuth = async () => {
const tg = window.Telegram?.WebApp
if (tg?.initData) {
setIsTelegramWebApp(true)
tg.ready()
tg.expand()
setIsLoading(true)
try {
await loginWithTelegram(tg.initData)
navigate('/')
} catch (err) {
console.error('Telegram auth failed:', err)
setError(t('auth.telegramRequired'))
} finally {
setIsLoading(false)
}
}
}
tryTelegramAuth()
}, [loginWithTelegram, navigate, t])
const handleEmailLogin = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setIsLoading(true)
try {
await loginWithEmail(email, password)
navigate('/')
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
setError(error.response?.data?.detail || t('common.error'))
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center py-8 px-4 sm:py-12 sm:px-6 lg:px-8">
{/* Background gradient */}
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
<div className="fixed inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-accent-500/10 via-transparent to-transparent" />
{/* Language switcher */}
<div className="fixed top-4 right-4 z-50">
<LanguageSwitcher />
</div>
<div className="relative max-w-md w-full space-y-8">
{/* Logo */}
<div className="text-center">
<div className="mx-auto w-16 h-16 rounded-2xl bg-gradient-to-br from-accent-400 to-accent-600 flex items-center justify-center mb-6 shadow-lg shadow-accent-500/30 overflow-hidden">
{branding?.has_custom_logo && logoUrl ? (
<img src={logoUrl} alt={appName || 'Logo'} className="w-full h-full object-cover" />
) : (
<span className="text-white font-bold text-2xl">{appLogo}</span>
)}
</div>
{appName && (
<h1 className="text-3xl font-bold text-dark-50">
{appName}
</h1>
)}
<p className="mt-2 text-dark-400">
{t('auth.loginSubtitle')}
</p>
</div>
{/* Card */}
<div className="card">
{/* Tabs */}
<div className="flex mb-6">
<button
className={`flex-1 py-3 text-sm font-medium transition-all border-b-2 ${
activeTab === 'telegram'
? 'border-accent-500 text-accent-400'
: 'border-transparent text-dark-500 hover:text-dark-300'
}`}
onClick={() => setActiveTab('telegram')}
>
Telegram
</button>
<button
className={`flex-1 py-3 text-sm font-medium transition-all border-b-2 ${
activeTab === 'email'
? 'border-accent-500 text-accent-400'
: 'border-transparent text-dark-500 hover:text-dark-300'
}`}
onClick={() => setActiveTab('email')}
>
Email
</button>
</div>
{error && (
<div className="bg-error-500/10 border border-error-500/30 text-error-400 px-4 py-3 rounded-xl text-sm mb-6">
{error}
</div>
)}
{activeTab === 'telegram' ? (
<div className="space-y-6">
<p className="text-center text-sm text-dark-400">
{t('auth.registerHint')}
</p>
{isLoading && isTelegramWebApp ? (
<div className="text-center py-8">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin mx-auto mb-3" />
<p className="text-sm text-dark-400">{t('auth.authenticating')}</p>
</div>
) : (
<TelegramLoginButton botUsername={botUsername} />
)}
</div>
) : (
<form className="space-y-5" onSubmit={handleEmailLogin}>
<div>
<label htmlFor="email" className="label">
{t('auth.email')}
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className="input"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<label htmlFor="password" className="label">
{t('auth.password')}
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="input"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<button
type="submit"
disabled={isLoading}
className="btn-primary w-full py-3"
>
{isLoading ? (
<span className="flex items-center justify-center gap-2">
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
{t('common.loading')}
</span>
) : (
t('auth.login')
)}
</button>
<p className="text-center text-xs text-dark-500">
{t('auth.registerHint')}
</p>
</form>
)}
</div>
</div>
</div>
)
}

243
src/pages/Polls.tsx Normal file
View File

@@ -0,0 +1,243 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { pollsApi, PollInfo, PollQuestion } from '../api/polls'
const ClipboardIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
</svg>
)
const GiftIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
)
const CheckIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)
export default function Polls() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [selectedPoll, setSelectedPoll] = useState<PollInfo | null>(null)
const [currentQuestion, setCurrentQuestion] = useState<PollQuestion | null>(null)
const [questionIndex, setQuestionIndex] = useState(0)
const [totalQuestions, setTotalQuestions] = useState(0)
const [completionMessage, setCompletionMessage] = useState<{ reward: number | null; message: string } | null>(null)
const { data: polls, isLoading, error } = useQuery({
queryKey: ['polls'],
queryFn: pollsApi.getPolls,
})
const startPollMutation = useMutation({
mutationFn: pollsApi.startPoll,
onSuccess: (data) => {
setCurrentQuestion(data.question)
setQuestionIndex(data.current_question_index)
setTotalQuestions(data.total_questions)
setCompletionMessage(null)
},
})
const answerMutation = useMutation({
mutationFn: ({
responseId,
questionId,
optionId,
}: {
responseId: number
questionId: number
optionId: number
}) => pollsApi.answerQuestion(responseId, questionId, optionId),
onSuccess: (data) => {
if (data.is_completed) {
setCurrentQuestion(null)
setCompletionMessage({
reward: data.reward_granted,
message: data.message || t('polls.completed'),
})
queryClient.invalidateQueries({ queryKey: ['polls'] })
} else if (data.next_question) {
setCurrentQuestion(data.next_question)
setQuestionIndex(data.current_question_index || 0)
setTotalQuestions(data.total_questions)
}
},
})
const handleStartPoll = (poll: PollInfo) => {
setSelectedPoll(poll)
startPollMutation.mutate(poll.response_id)
}
const handleAnswer = (optionId: number) => {
if (selectedPoll && currentQuestion) {
answerMutation.mutate({
responseId: selectedPoll.response_id,
questionId: currentQuestion.id,
optionId,
})
}
}
const handleClosePoll = () => {
setSelectedPoll(null)
setCurrentQuestion(null)
setCompletionMessage(null)
}
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-64">
<div className="w-10 h-10 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (error) {
return (
<div className="card bg-red-500/10 border-red-500/20">
<p className="text-red-400">{t('polls.error')}</p>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<ClipboardIcon />
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('polls.title')}</h1>
</div>
{/* Poll Modal */}
{selectedPoll && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60">
<div className="card max-w-lg w-full max-h-[80vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">{selectedPoll.title}</h2>
<button onClick={handleClosePoll} className="text-dark-400 hover:text-dark-200">
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{startPollMutation.isPending && (
<div className="flex justify-center py-8">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)}
{completionMessage && (
<div className="space-y-4">
<div className="p-4 rounded-lg bg-success-500/20 text-success-400 text-center">
<CheckIcon />
<p className="font-medium mt-2">{completionMessage.message}</p>
{completionMessage.reward && (
<p className="text-sm mt-1">
+{completionMessage.reward} {t('polls.reward')}
</p>
)}
</div>
<button onClick={handleClosePoll} className="w-full btn-secondary">
{t('common.close')}
</button>
</div>
)}
{currentQuestion && !completionMessage && (
<div className="space-y-4">
<div className="text-sm text-dark-400">
{t('polls.question')} {questionIndex + 1} {t('polls.of')} {totalQuestions}
</div>
<div className="w-full bg-dark-700 rounded-full h-2">
<div
className="bg-accent-500 h-2 rounded-full transition-all"
style={{ width: `${((questionIndex + 1) / totalQuestions) * 100}%` }}
/>
</div>
<p className="text-lg font-medium">{currentQuestion.text}</p>
<div className="space-y-2">
{currentQuestion.options.map((option) => (
<button
key={option.id}
onClick={() => handleAnswer(option.id)}
disabled={answerMutation.isPending}
className="w-full p-4 text-left bg-dark-700 hover:bg-dark-600 rounded-lg transition-colors disabled:opacity-50"
>
{option.text}
</button>
))}
</div>
{answerMutation.isPending && (
<div className="flex justify-center">
<div className="w-6 h-6 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)}
</div>
)}
</div>
</div>
)}
{/* Polls List */}
{polls && polls.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2">
{polls.map((poll) => (
<div key={poll.id} className="card">
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-semibold text-lg">{poll.title}</h3>
{poll.description && (
<p className="text-dark-400 text-sm mt-1">{poll.description}</p>
)}
<div className="flex items-center gap-4 mt-2 text-sm text-dark-400">
<span>
{poll.answered_questions}/{poll.total_questions} {t('polls.questions')}
</span>
</div>
</div>
{poll.reward_amount && (
<div className="flex items-center gap-1 text-accent-400">
<GiftIcon />
<span className="text-sm font-medium">+{poll.reward_amount}</span>
</div>
)}
</div>
<div className="mt-4">
{poll.is_completed ? (
<button disabled className="w-full btn-secondary opacity-50 cursor-not-allowed">
<CheckIcon />
<span className="ml-2">{t('polls.completed')}</span>
</button>
) : (
<button
onClick={() => handleStartPoll(poll)}
className="w-full btn-primary"
>
{poll.answered_questions > 0 ? t('polls.continue') : t('polls.start')}
</button>
)}
</div>
</div>
))}
</div>
) : (
<div className="card text-center py-12">
<ClipboardIcon />
<p className="text-dark-400 mt-4">{t('polls.noPolls')}</p>
</div>
)}
</div>
)
}

414
src/pages/Profile.tsx Normal file
View File

@@ -0,0 +1,414 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuthStore } from '../store/auth'
import { authApi } from '../api/auth'
import { notificationsApi, NotificationSettings, NotificationSettingsUpdate } from '../api/notifications'
export default function Profile() {
const { t } = useTranslation()
const { user, setUser } = useAuthStore()
const queryClient = useQueryClient()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const registerEmailMutation = useMutation({
mutationFn: ({ email, password }: { email: string; password: string }) =>
authApi.registerEmail(email, password),
onSuccess: async () => {
setSuccess(t('profile.emailSent'))
setError(null)
setEmail('')
setPassword('')
setConfirmPassword('')
const updatedUser = await authApi.getMe()
setUser(updatedUser)
queryClient.invalidateQueries({ queryKey: ['user'] })
},
onError: (err: { response?: { data?: { detail?: string } } }) => {
setError(err.response?.data?.detail || t('common.error'))
setSuccess(null)
},
})
const resendVerificationMutation = useMutation({
mutationFn: authApi.resendVerification,
onSuccess: () => {
setSuccess(t('profile.verificationResent'))
setError(null)
},
onError: (err: { response?: { data?: { detail?: string } } }) => {
setError(err.response?.data?.detail || t('common.error'))
setSuccess(null)
},
})
const { data: notificationSettings, isLoading: notificationsLoading } = useQuery({
queryKey: ['notification-settings'],
queryFn: notificationsApi.getSettings,
})
const updateNotificationsMutation = useMutation({
mutationFn: notificationsApi.updateSettings,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notification-settings'] })
},
})
const handleNotificationToggle = (key: keyof NotificationSettings, value: boolean) => {
const update: NotificationSettingsUpdate = { [key]: value }
updateNotificationsMutation.mutate(update)
}
const handleNotificationValue = (key: keyof NotificationSettings, value: number) => {
const update: NotificationSettingsUpdate = { [key]: value }
updateNotificationsMutation.mutate(update)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setSuccess(null)
if (!email.trim()) {
setError(t('profile.emailRequired'))
return
}
if (!password || password.length < 8) {
setError(t('profile.passwordMinLength'))
return
}
if (password !== confirmPassword) {
setError(t('profile.passwordsMismatch'))
return
}
registerEmailMutation.mutate({ email, password })
}
return (
<div className="space-y-6">
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('profile.title')}</h1>
{/* User Info Card */}
<div className="card">
<h2 className="text-lg font-semibold text-dark-100 mb-6">{t('profile.accountInfo')}</h2>
<div className="space-y-4">
<div className="flex justify-between items-center py-3 border-b border-dark-800/50">
<span className="text-dark-400">{t('profile.telegramId')}</span>
<span className="text-dark-100 font-medium">{user?.telegram_id}</span>
</div>
{user?.username && (
<div className="flex justify-between items-center py-3 border-b border-dark-800/50">
<span className="text-dark-400">{t('profile.username')}</span>
<span className="text-dark-100 font-medium">@{user.username}</span>
</div>
)}
<div className="flex justify-between items-center py-3 border-b border-dark-800/50">
<span className="text-dark-400">{t('profile.name')}</span>
<span className="text-dark-100 font-medium">
{user?.first_name} {user?.last_name}
</span>
</div>
<div className="flex justify-between items-center py-3">
<span className="text-dark-400">{t('profile.registeredAt')}</span>
<span className="text-dark-100 font-medium">
{user?.created_at ? new Date(user.created_at).toLocaleDateString() : '-'}
</span>
</div>
</div>
</div>
{/* Email Section */}
<div className="card">
<h2 className="text-lg font-semibold text-dark-100 mb-6">{t('profile.emailAuth')}</h2>
{user?.email ? (
<div className="space-y-4">
<div className="flex justify-between items-center py-3 border-b border-dark-800/50">
<span className="text-dark-400">Email</span>
<div className="flex items-center gap-3">
<span className="text-dark-100 font-medium">{user.email}</span>
{user.email_verified ? (
<span className="badge-success">{t('profile.verified')}</span>
) : (
<span className="badge-warning">{t('profile.notVerified')}</span>
)}
</div>
</div>
{!user.email_verified && (
<div className="bg-warning-500/10 border border-warning-500/30 rounded-xl p-4">
<p className="text-sm text-warning-400 mb-4">
{t('profile.verificationRequired')}
</p>
<button
onClick={() => resendVerificationMutation.mutate()}
disabled={resendVerificationMutation.isPending}
className="btn-primary"
>
{resendVerificationMutation.isPending
? t('common.loading')
: t('profile.resendVerification')}
</button>
</div>
)}
{user.email_verified && (
<p className="text-sm text-dark-400">{t('profile.canLoginWithEmail')}</p>
)}
</div>
) : (
<div>
<p className="text-sm text-dark-400 mb-6">{t('profile.linkEmailDescription')}</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="label">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
className="input"
/>
</div>
<div>
<label className="label">{t('auth.password')}</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('profile.passwordPlaceholder')}
className="input"
/>
<p className="text-xs text-dark-500 mt-2">{t('profile.passwordHint')}</p>
</div>
<div>
<label className="label">{t('auth.confirmPassword')}</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={t('profile.confirmPasswordPlaceholder')}
className="input"
/>
</div>
{error && (
<div className="bg-error-500/10 border border-error-500/30 text-error-400 p-4 rounded-xl text-sm">
{error}
</div>
)}
{success && (
<div className="bg-success-500/10 border border-success-500/30 text-success-400 p-4 rounded-xl text-sm">
{success}
</div>
)}
<button
type="submit"
disabled={registerEmailMutation.isPending}
className="btn-primary w-full"
>
{registerEmailMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
{t('common.loading')}
</span>
) : (
t('profile.linkEmail')
)}
</button>
</form>
</div>
)}
{(error || success) && user?.email && (
<div className="mt-4">
{error && (
<div className="bg-error-500/10 border border-error-500/30 text-error-400 p-4 rounded-xl text-sm">
{error}
</div>
)}
{success && (
<div className="bg-success-500/10 border border-success-500/30 text-success-400 p-4 rounded-xl text-sm">
{success}
</div>
)}
</div>
)}
</div>
{/* Notification Settings */}
<div className="card">
<h2 className="text-lg font-semibold text-dark-100 mb-6">{t('profile.notifications.title')}</h2>
{notificationsLoading ? (
<div className="flex justify-center py-4">
<div className="w-6 h-6 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
) : notificationSettings ? (
<div className="space-y-6">
{/* Subscription Expiry */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-dark-100">{t('profile.notifications.subscriptionExpiry')}</p>
<p className="text-sm text-dark-400">{t('profile.notifications.subscriptionExpiryDesc')}</p>
</div>
<button
onClick={() => handleNotificationToggle('subscription_expiry_enabled', !notificationSettings.subscription_expiry_enabled)}
className={`relative w-12 h-6 rounded-full transition-colors ${
notificationSettings.subscription_expiry_enabled ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${
notificationSettings.subscription_expiry_enabled ? 'left-7' : 'left-1'
}`}
/>
</button>
</div>
{notificationSettings.subscription_expiry_enabled && (
<div className="flex items-center gap-3 pl-4">
<span className="text-sm text-dark-400">{t('profile.notifications.daysBeforeExpiry')}</span>
<select
value={notificationSettings.subscription_expiry_days}
onChange={(e) => handleNotificationValue('subscription_expiry_days', Number(e.target.value))}
className="input w-20 py-1"
>
{[1, 2, 3, 5, 7, 14].map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
</div>
)}
</div>
{/* Traffic Warning */}
<div className="space-y-3 border-t border-dark-800/50 pt-6">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-dark-100">{t('profile.notifications.trafficWarning')}</p>
<p className="text-sm text-dark-400">{t('profile.notifications.trafficWarningDesc')}</p>
</div>
<button
onClick={() => handleNotificationToggle('traffic_warning_enabled', !notificationSettings.traffic_warning_enabled)}
className={`relative w-12 h-6 rounded-full transition-colors ${
notificationSettings.traffic_warning_enabled ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${
notificationSettings.traffic_warning_enabled ? 'left-7' : 'left-1'
}`}
/>
</button>
</div>
{notificationSettings.traffic_warning_enabled && (
<div className="flex items-center gap-3 pl-4">
<span className="text-sm text-dark-400">{t('profile.notifications.atPercent')}</span>
<select
value={notificationSettings.traffic_warning_percent}
onChange={(e) => handleNotificationValue('traffic_warning_percent', Number(e.target.value))}
className="input w-20 py-1"
>
{[50, 70, 80, 90, 95].map((p) => (
<option key={p} value={p}>{p}%</option>
))}
</select>
</div>
)}
</div>
{/* Balance Low */}
<div className="space-y-3 border-t border-dark-800/50 pt-6">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-dark-100">{t('profile.notifications.balanceLow')}</p>
<p className="text-sm text-dark-400">{t('profile.notifications.balanceLowDesc')}</p>
</div>
<button
onClick={() => handleNotificationToggle('balance_low_enabled', !notificationSettings.balance_low_enabled)}
className={`relative w-12 h-6 rounded-full transition-colors ${
notificationSettings.balance_low_enabled ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${
notificationSettings.balance_low_enabled ? 'left-7' : 'left-1'
}`}
/>
</button>
</div>
{notificationSettings.balance_low_enabled && (
<div className="flex items-center gap-3 pl-4">
<span className="text-sm text-dark-400">{t('profile.notifications.threshold')}</span>
<input
type="number"
value={notificationSettings.balance_low_threshold}
onChange={(e) => handleNotificationValue('balance_low_threshold', Number(e.target.value))}
min={0}
className="input w-24 py-1"
/>
</div>
)}
</div>
{/* News */}
<div className="flex items-center justify-between border-t border-dark-800/50 pt-6">
<div>
<p className="font-medium text-dark-100">{t('profile.notifications.news')}</p>
<p className="text-sm text-dark-400">{t('profile.notifications.newsDesc')}</p>
</div>
<button
onClick={() => handleNotificationToggle('news_enabled', !notificationSettings.news_enabled)}
className={`relative w-12 h-6 rounded-full transition-colors ${
notificationSettings.news_enabled ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${
notificationSettings.news_enabled ? 'left-7' : 'left-1'
}`}
/>
</button>
</div>
{/* Promo Offers */}
<div className="flex items-center justify-between border-t border-dark-800/50 pt-6">
<div>
<p className="font-medium text-dark-100">{t('profile.notifications.promoOffers')}</p>
<p className="text-sm text-dark-400">{t('profile.notifications.promoOffersDesc')}</p>
</div>
<button
onClick={() => handleNotificationToggle('promo_offers_enabled', !notificationSettings.promo_offers_enabled)}
className={`relative w-12 h-6 rounded-full transition-colors ${
notificationSettings.promo_offers_enabled ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${
notificationSettings.promo_offers_enabled ? 'left-7' : 'left-1'
}`}
/>
</button>
</div>
</div>
) : (
<p className="text-dark-400">{t('profile.notifications.unavailable')}</p>
)}
</div>
</div>
)
}

248
src/pages/Referral.tsx Normal file
View File

@@ -0,0 +1,248 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { referralApi } from '../api/referral'
import { useCurrency } from '../hooks/useCurrency'
const CopyIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
</svg>
)
const CheckIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)
const ShareIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8l5-5m0 0l5 5m-5-5v12" />
<path strokeLinecap="round" strokeLinejoin="round" d="M4 15v3a2 2 0 002 2h12a2 2 0 002-2v-3" />
</svg>
)
export default function Referral() {
const { t } = useTranslation()
const { formatAmount, currencySymbol, formatPositive } = useCurrency()
const [copied, setCopied] = useState(false)
const { data: info, isLoading } = useQuery({
queryKey: ['referral-info'],
queryFn: referralApi.getReferralInfo,
})
const { data: terms } = useQuery({
queryKey: ['referral-terms'],
queryFn: referralApi.getReferralTerms,
})
const { data: referralList } = useQuery({
queryKey: ['referral-list'],
queryFn: () => referralApi.getReferralList({ per_page: 10 }),
})
const { data: earnings } = useQuery({
queryKey: ['referral-earnings'],
queryFn: () => referralApi.getReferralEarnings({ per_page: 10 }),
})
const copyLink = () => {
if (info?.referral_link) {
navigator.clipboard.writeText(info.referral_link)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
const shareLink = () => {
if (!info?.referral_link) return
const shareText = t('referral.shareMessage', {
percent: info?.commission_percent || 0,
})
if (navigator.share) {
navigator
.share({
title: t('referral.title'),
text: shareText,
url: info.referral_link,
})
.catch(() => {
// ignore cancellation errors
})
return
}
const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(
info.referral_link
)}&text=${encodeURIComponent(shareText)}`
window.open(telegramUrl, '_blank', 'noopener,noreferrer')
}
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-64">
<div className="w-10 h-10 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)
}
return (
<div className="space-y-6">
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('referral.title')}</h1>
{/* Stats Cards */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
<div className="card">
<div className="text-sm text-dark-400">{t('referral.stats.totalReferrals')}</div>
<div className="stat-value mt-1">{info?.total_referrals || 0}</div>
<div className="text-sm text-dark-500 mt-1">
{info?.active_referrals || 0} {t('referral.stats.activeReferrals').toLowerCase()}
</div>
</div>
<div className="card">
<div className="text-sm text-dark-400">{t('referral.stats.totalEarnings')}</div>
<div className="stat-value text-success-400 mt-1">
{formatPositive(info?.total_earnings_rubles || 0)}
</div>
</div>
<div className="card col-span-2 sm:col-span-1">
<div className="text-sm text-dark-400">{t('referral.stats.commissionRate')}</div>
<div className="stat-value text-accent-400 mt-1">
{info?.commission_percent || 0}%
</div>
</div>
</div>
{/* Referral Link */}
<div className="card">
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('referral.yourLink')}</h2>
<div className="flex flex-col gap-2 sm:flex-row">
<input
type="text"
readOnly
value={info?.referral_link || ''}
className="input flex-1"
/>
<div className="flex gap-2">
<button
onClick={copyLink}
disabled={!info?.referral_link}
className={`btn-primary px-5 ${
copied ? 'bg-success-500 hover:bg-success-500' : ''
} ${!info?.referral_link ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{copied ? <CheckIcon /> : <CopyIcon />}
<span className="ml-2">{copied ? t('referral.copied') : t('referral.copyLink')}</span>
</button>
<button
onClick={shareLink}
disabled={!info?.referral_link}
className={`btn-secondary px-5 flex items-center ${
!info?.referral_link ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
<ShareIcon />
<span className="ml-2">{t('referral.shareButton')}</span>
</button>
</div>
</div>
<p className="mt-3 text-sm text-dark-500">
{t('referral.shareHint', { percent: info?.commission_percent || 0 })}
</p>
</div>
{/* Program Terms */}
{terms && (
<div className="card">
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('referral.terms.title')}</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-3 rounded-xl bg-dark-800/30">
<div className="text-sm text-dark-500">{t('referral.terms.commission')}</div>
<div className="text-lg font-semibold text-dark-100 mt-1">{terms.commission_percent}%</div>
</div>
<div className="p-3 rounded-xl bg-dark-800/30">
<div className="text-sm text-dark-500">{t('referral.terms.minTopup')}</div>
<div className="text-lg font-semibold text-dark-100 mt-1">{formatAmount(terms.minimum_topup_rubles)} {currencySymbol}</div>
</div>
<div className="p-3 rounded-xl bg-dark-800/30">
<div className="text-sm text-dark-500">{t('referral.terms.newUserBonus')}</div>
<div className="text-lg font-semibold text-success-400 mt-1">{formatPositive(terms.first_topup_bonus_rubles)}</div>
</div>
<div className="p-3 rounded-xl bg-dark-800/30">
<div className="text-sm text-dark-500">{t('referral.terms.inviterBonus')}</div>
<div className="text-lg font-semibold text-success-400 mt-1">{formatPositive(terms.inviter_bonus_rubles)}</div>
</div>
</div>
</div>
)}
{/* Referrals List */}
<div className="card">
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('referral.yourReferrals')}</h2>
{referralList?.items && referralList.items.length > 0 ? (
<div className="space-y-3">
{referralList.items.map((ref) => (
<div
key={ref.id}
className="flex items-center justify-between p-3 rounded-xl bg-dark-800/30 border border-dark-700/30"
>
<div>
<div className="text-dark-100 font-medium">
{ref.first_name || ref.username || `User #${ref.id}`}
</div>
<div className="text-xs text-dark-500 mt-0.5">
{new Date(ref.created_at).toLocaleDateString()}
</div>
</div>
{ref.has_paid ? (
<span className="badge-success">Paid</span>
) : (
<span className="badge-neutral">Pending</span>
)}
</div>
))}
</div>
) : (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
<svg className="w-8 h-8 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
</div>
<div className="text-dark-400">{t('referral.noReferrals')}</div>
</div>
)}
</div>
{/* Earnings History */}
{earnings?.items && earnings.items.length > 0 && (
<div className="card">
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('referral.earningsHistory')}</h2>
<div className="space-y-3">
{earnings.items.map((earning) => (
<div
key={earning.id}
className="flex items-center justify-between p-3 rounded-xl bg-dark-800/30 border border-dark-700/30"
>
<div>
<div className="text-dark-100">
{earning.referral_first_name || earning.referral_username || 'Referral'}
</div>
<div className="text-xs text-dark-500 mt-0.5">
{earning.reason} {new Date(earning.created_at).toLocaleDateString()}
</div>
</div>
<div className="text-success-400 font-semibold">
{formatPositive(earning.amount_rubles)}
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}

1732
src/pages/Subscription.tsx Normal file

File diff suppressed because it is too large Load Diff

682
src/pages/Support.tsx Normal file
View File

@@ -0,0 +1,682 @@
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { ticketsApi } from '../api/tickets'
import { infoApi } from '../api/info'
import type { TicketDetail, TicketMessage } from '../types'
const PlusIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
)
const SendIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
</svg>
)
const ImageIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
</svg>
)
const CloseIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)
// Media attachment state
interface MediaAttachment {
file: File
preview: string
uploading: boolean
fileId?: string
error?: string
}
// Message media display component
function MessageMedia({ message, t }: { message: TicketMessage; t: (key: string) => string }) {
const [imageLoaded, setImageLoaded] = useState(false)
const [imageError, setImageError] = useState(false)
const [showFullImage, setShowFullImage] = useState(false)
if (!message.has_media || !message.media_file_id) {
return null
}
const mediaUrl = ticketsApi.getMediaUrl(message.media_file_id)
if (message.media_type === 'photo') {
return (
<>
<div className="mt-3 relative">
{!imageLoaded && !imageError && (
<div className="w-full h-48 bg-dark-700 rounded-lg animate-pulse flex items-center justify-center">
<div className="w-6 h-6 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)}
{imageError ? (
<div className="w-full h-32 bg-dark-700 rounded-lg flex items-center justify-center text-dark-400 text-sm">
{t('support.imageLoadFailed')}
</div>
) : (
<img
src={mediaUrl}
alt={message.media_caption || 'Attached image'}
className={`max-w-full max-h-64 rounded-lg cursor-pointer hover:opacity-90 transition-opacity ${imageLoaded ? '' : 'hidden'}`}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
onClick={() => setShowFullImage(true)}
/>
)}
{message.media_caption && (
<p className="text-xs text-dark-400 mt-1">{message.media_caption}</p>
)}
</div>
{/* Full image modal */}
{showFullImage && (
<div
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4"
onClick={() => setShowFullImage(false)}
>
<button
className="absolute top-4 right-4 text-white/70 hover:text-white"
onClick={() => setShowFullImage(false)}
>
<CloseIcon />
</button>
<img
src={mediaUrl}
alt={message.media_caption || 'Attached image'}
className="max-w-full max-h-full object-contain"
/>
</div>
)}
</>
)
}
// For documents/videos - show download link
return (
<div className="mt-3">
<a
href={mediaUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-2 bg-dark-700 hover:bg-dark-600 rounded-lg text-sm text-dark-200 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m.75 12l3 3m0 0l3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
{message.media_caption || `Download ${message.media_type}`}
</a>
</div>
)
}
export default function Support() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [selectedTicket, setSelectedTicket] = useState<TicketDetail | null>(null)
const [showCreateForm, setShowCreateForm] = useState(false)
const [newTitle, setNewTitle] = useState('')
const [newMessage, setNewMessage] = useState('')
const [replyMessage, setReplyMessage] = useState('')
// Media attachment states
const [createAttachment, setCreateAttachment] = useState<MediaAttachment | null>(null)
const [replyAttachment, setReplyAttachment] = useState<MediaAttachment | null>(null)
const createFileInputRef = useRef<HTMLInputElement>(null)
const replyFileInputRef = useRef<HTMLInputElement>(null)
// Get support configuration
const { data: supportConfig, isLoading: configLoading } = useQuery({
queryKey: ['support-config'],
queryFn: infoApi.getSupportConfig,
})
const { data: tickets, isLoading } = useQuery({
queryKey: ['tickets'],
queryFn: () => ticketsApi.getTickets({ per_page: 20 }),
enabled: supportConfig?.tickets_enabled === true,
})
const { data: ticketDetail, isLoading: detailLoading } = useQuery({
queryKey: ['ticket', selectedTicket?.id],
queryFn: () => ticketsApi.getTicket(selectedTicket!.id),
enabled: !!selectedTicket,
})
// Handle file selection
const handleFileSelect = async (
file: File,
setAttachment: (a: MediaAttachment | null) => void
) => {
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
if (!allowedTypes.includes(file.type)) {
setAttachment({
file,
preview: '',
uploading: false,
error: t('support.invalidFileType') || 'Invalid file type. Use JPEG, PNG, GIF, or WebP.',
})
return
}
// Validate file size (10MB)
if (file.size > 10 * 1024 * 1024) {
setAttachment({
file,
preview: '',
uploading: false,
error: t('support.fileTooLarge') || 'File is too large. Maximum size is 10MB.',
})
return
}
// Create preview
const preview = URL.createObjectURL(file)
setAttachment({ file, preview, uploading: true })
try {
const result = await ticketsApi.uploadMedia(file, 'photo')
setAttachment({
file,
preview,
uploading: false,
fileId: result.file_id,
})
} catch (error) {
setAttachment({
file,
preview,
uploading: false,
error: t('support.uploadFailed') || 'Failed to upload image',
})
}
}
const createMutation = useMutation({
mutationFn: async () => {
const media = createAttachment?.fileId
? {
media_type: 'photo',
media_file_id: createAttachment.fileId,
}
: undefined
return ticketsApi.createTicket(newTitle, newMessage, media)
},
onSuccess: (ticket) => {
queryClient.invalidateQueries({ queryKey: ['tickets'] })
setShowCreateForm(false)
setNewTitle('')
setNewMessage('')
setCreateAttachment(null)
setSelectedTicket(ticket)
},
})
const replyMutation = useMutation({
mutationFn: async () => {
const media = replyAttachment?.fileId
? {
media_type: 'photo',
media_file_id: replyAttachment.fileId,
}
: undefined
return ticketsApi.addMessage(selectedTicket!.id, replyMessage, media)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ticket', selectedTicket?.id] })
setReplyMessage('')
setReplyAttachment(null)
},
})
const getStatusBadge = (status: string) => {
switch (status) {
case 'open':
return 'badge-info'
case 'answered':
return 'badge-success'
case 'pending':
return 'badge-warning'
case 'closed':
return 'badge-neutral'
default:
return 'badge-neutral'
}
}
const getStatusLabel = (status: string) => {
return t(`support.status.${status}`) || status
}
// Show loading while checking configuration
if (configLoading) {
return (
<div className="flex items-center justify-center py-24">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)
}
// If tickets are disabled, show redirect message
if (supportConfig && !supportConfig.tickets_enabled) {
const getSupportMessage = () => {
if (supportConfig.support_type === 'profile') {
return {
title: t('support.ticketsDisabled'),
message: t('support.useProfile'),
buttonText: t('support.goToProfile'),
buttonAction: () => {
const webApp = window.Telegram?.WebApp
if (webApp?.close) {
webApp.close()
}
},
}
}
if (supportConfig.support_type === 'url' && supportConfig.support_url) {
return {
title: t('support.ticketsDisabled'),
message: t('support.useExternalLink'),
buttonText: t('support.openSupport'),
buttonAction: () => {
const webApp = window.Telegram?.WebApp
if (webApp?.openLink) {
webApp.openLink(supportConfig.support_url!)
} else {
window.open(supportConfig.support_url!, '_blank')
}
},
}
}
const supportUsername = supportConfig.support_username || '@support'
return {
title: t('support.ticketsDisabled'),
message: t('support.contactSupport', { username: supportUsername }),
buttonText: t('support.contactUs'),
buttonAction: () => {
const webApp = window.Telegram?.WebApp
const url = supportUsername.startsWith('@')
? `https://t.me/${supportUsername.slice(1)}`
: `https://t.me/${supportUsername}`
if (webApp?.openTelegramLink) {
webApp.openTelegramLink(url)
} else {
window.open(url, '_blank')
}
},
}
}
const supportMessage = getSupportMessage()
return (
<div className="max-w-md mx-auto mt-12">
<div className="card text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
<svg className="w-8 h-8 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
</svg>
</div>
<h2 className="text-xl font-semibold text-dark-100 mb-2">{supportMessage.title}</h2>
<p className="text-dark-400 mb-6">{supportMessage.message}</p>
<button onClick={supportMessage.buttonAction} className="btn-primary w-full">
{supportMessage.buttonText}
</button>
</div>
</div>
)
}
// Attachment preview component
const AttachmentPreview = ({
attachment,
onRemove,
}: {
attachment: MediaAttachment
onRemove: () => void
}) => (
<div className="relative inline-block mt-2">
{attachment.preview && (
<img
src={attachment.preview}
alt="Attachment preview"
className="h-20 w-auto rounded-lg border border-dark-700"
/>
)}
{attachment.uploading && (
<div className="absolute inset-0 bg-dark-900/70 rounded-lg flex items-center justify-center">
<div className="w-5 h-5 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)}
{attachment.error && (
<div className="text-xs text-red-400 mt-1">{attachment.error}</div>
)}
<button
type="button"
onClick={onRemove}
className="absolute -top-2 -right-2 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center text-white hover:bg-red-600"
>
<CloseIcon />
</button>
</div>
)
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('support.title')}</h1>
<button
onClick={() => {
setShowCreateForm(true)
setSelectedTicket(null)
setCreateAttachment(null)
}}
className="btn-primary"
>
<PlusIcon />
{t('support.newTicket')}
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Tickets List */}
<div className="lg:col-span-1 card">
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('support.yourTickets')}</h2>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
) : tickets?.items && tickets.items.length > 0 ? (
<div className="space-y-2">
{tickets.items.map((ticket) => (
<button
key={ticket.id}
onClick={() => {
setSelectedTicket(ticket as unknown as TicketDetail)
setShowCreateForm(false)
setReplyAttachment(null)
}}
className={`w-full text-left p-4 rounded-xl border transition-all ${
selectedTicket?.id === ticket.id
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
}`}
>
<div className="flex justify-between items-start gap-2 mb-2">
<div className="text-dark-100 font-medium truncate">{ticket.title}</div>
<span className={`${getStatusBadge(ticket.status)} flex-shrink-0`}>
{getStatusLabel(ticket.status)}
</span>
</div>
<div className="text-xs text-dark-500">
{new Date(ticket.updated_at).toLocaleDateString()}
</div>
</button>
))}
</div>
) : (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
<svg className="w-8 h-8 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
</svg>
</div>
<div className="text-dark-400">{t('support.noTickets')}</div>
</div>
)}
</div>
{/* Ticket Detail / Create Form */}
<div className="lg:col-span-2 card">
{showCreateForm ? (
<div>
<h2 className="text-lg font-semibold text-dark-100 mb-6">{t('support.createTicket')}</h2>
<form
onSubmit={(e) => {
e.preventDefault()
createMutation.mutate()
}}
className="space-y-4"
>
<div>
<label className="label">{t('support.subject')}</label>
<input
type="text"
className="input"
placeholder={t('support.subjectPlaceholder')}
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
required
minLength={3}
maxLength={255}
/>
</div>
<div>
<label className="label">{t('support.message')}</label>
<textarea
className="input min-h-[150px]"
placeholder={t('support.messagePlaceholder')}
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
required
minLength={10}
maxLength={4000}
/>
</div>
{/* Image attachment for create */}
<div>
<input
ref={createFileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleFileSelect(file, setCreateAttachment)
e.target.value = ''
}}
/>
{createAttachment ? (
<AttachmentPreview
attachment={createAttachment}
onRemove={() => setCreateAttachment(null)}
/>
) : (
<button
type="button"
onClick={() => createFileInputRef.current?.click()}
className="flex items-center gap-2 text-sm text-dark-400 hover:text-dark-200 transition-colors"
>
<ImageIcon />
{t('support.attachImage')}
</button>
)}
</div>
<div className="flex gap-3">
<button
type="submit"
disabled={createMutation.isPending || (createAttachment?.uploading)}
className="btn-primary"
>
{createMutation.isPending ? (
<span className="flex items-center gap-2">
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
{t('support.creating')}
</span>
) : (
<>
<SendIcon />
{t('support.send')}
</>
)}
</button>
<button
type="button"
onClick={() => {
setShowCreateForm(false)
setCreateAttachment(null)
}}
className="btn-secondary"
>
{t('common.cancel')}
</button>
</div>
</form>
</div>
) : selectedTicket ? (
<div className="flex flex-col h-full">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-2 mb-6 pb-4 border-b border-dark-800/50">
<div>
<h2 className="text-lg font-semibold text-dark-100">
{ticketDetail?.title || selectedTicket.title}
</h2>
<div className="flex items-center flex-wrap gap-2 mt-2">
<span className={getStatusBadge(ticketDetail?.status || selectedTicket.status)}>
{getStatusLabel(ticketDetail?.status || selectedTicket.status)}
</span>
<span className="text-xs text-dark-500">
{t('support.created')} {new Date(selectedTicket.created_at).toLocaleDateString()}
</span>
</div>
</div>
</div>
{/* Messages */}
{detailLoading ? (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
) : ticketDetail?.messages ? (
<div className="flex-1 space-y-4 mb-6 max-h-96 overflow-y-auto scrollbar-hide">
{ticketDetail.messages.map((msg) => (
<div
key={msg.id}
className={`p-4 rounded-xl ${
msg.is_from_admin
? 'bg-accent-500/10 border border-accent-500/20 ml-4'
: 'bg-dark-800/50 border border-dark-700/30 mr-4'
}`}
>
<div className="flex justify-between items-center mb-2">
<span className={`text-xs font-medium ${msg.is_from_admin ? 'text-accent-400' : 'text-dark-400'}`}>
{msg.is_from_admin ? t('support.supportTeam') : t('support.you')}
</span>
<span className="text-xs text-dark-500">
{new Date(msg.created_at).toLocaleString()}
</span>
</div>
<div className="text-dark-200 whitespace-pre-wrap">
{msg.message_text}
</div>
{/* Display media if present */}
<MessageMedia message={msg} t={t} />
</div>
))}
</div>
) : null}
{/* Reply Form */}
{ticketDetail?.status !== 'closed' && !ticketDetail?.is_reply_blocked && (
<form
onSubmit={(e) => {
e.preventDefault()
replyMutation.mutate()
}}
className="pt-4 border-t border-dark-800/50"
>
<div className="space-y-3">
<div className="flex gap-3">
<textarea
className="input flex-1 min-h-[80px]"
placeholder={t('support.replyPlaceholder')}
value={replyMessage}
onChange={(e) => setReplyMessage(e.target.value)}
required
minLength={1}
maxLength={4000}
/>
</div>
{/* Image attachment for reply */}
<div className="flex items-center justify-between">
<div>
<input
ref={replyFileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleFileSelect(file, setReplyAttachment)
e.target.value = ''
}}
/>
{replyAttachment ? (
<AttachmentPreview
attachment={replyAttachment}
onRemove={() => setReplyAttachment(null)}
/>
) : (
<button
type="button"
onClick={() => replyFileInputRef.current?.click()}
className="flex items-center gap-2 text-sm text-dark-400 hover:text-dark-200 transition-colors"
>
<ImageIcon />
{t('support.attachImage')}
</button>
)}
</div>
<button
type="submit"
disabled={replyMutation.isPending || !replyMessage.trim() || replyAttachment?.uploading}
className="btn-primary"
>
{replyMutation.isPending ? (
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<SendIcon />
)}
</button>
</div>
</div>
</form>
)}
{ticketDetail?.is_reply_blocked && (
<div className="text-center py-4 text-sm text-dark-500 border-t border-dark-800/50">
{t('support.repliesDisabled')}
</div>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center py-16">
<div className="w-16 h-16 mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
<svg className="w-8 h-8 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
</svg>
</div>
<div className="text-dark-400">{t('support.selectTicket')}</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,87 @@
import { useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuthStore } from '../store/auth'
export default function TelegramCallback() {
const { t } = useTranslation()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const [error, setError] = useState('')
const { loginWithTelegramWidget, isAuthenticated } = useAuthStore()
useEffect(() => {
if (isAuthenticated) {
navigate('/')
return
}
const authenticate = async () => {
// Get auth data from URL params
const id = searchParams.get('id')
const firstName = searchParams.get('first_name')
const lastName = searchParams.get('last_name')
const username = searchParams.get('username')
const photoUrl = searchParams.get('photo_url')
const authDate = searchParams.get('auth_date')
const hash = searchParams.get('hash')
// Validate required fields
if (!id || !firstName || !authDate || !hash) {
setError(t('auth.telegramRequired'))
return
}
try {
await loginWithTelegramWidget({
id: parseInt(id, 10),
first_name: firstName,
last_name: lastName || undefined,
username: username || undefined,
photo_url: photoUrl || undefined,
auth_date: parseInt(authDate, 10),
hash: hash,
})
navigate('/')
} catch (err: unknown) {
console.error('Telegram auth error:', err)
const error = err as { response?: { data?: { detail?: string } } }
setError(error.response?.data?.detail || t('common.error'))
}
}
authenticate()
}, [searchParams, loginWithTelegramWidget, navigate, isAuthenticated, t])
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-8 px-4">
<div className="max-w-md w-full text-center">
<div className="text-red-500 text-5xl mb-4"></div>
<h2 className="text-lg font-semibold text-gray-900 mb-2">
{t('auth.loginFailed')}
</h2>
<p className="text-sm text-gray-500 mb-6">{error}</p>
<button
onClick={() => navigate('/login')}
className="btn-primary"
>
{t('auth.tryAgain')}
</button>
</div>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
<h2 className="text-lg font-semibold text-gray-900">
{t('auth.authenticating')}
</h2>
<p className="text-sm text-gray-500 mt-2">{t('common.loading')}</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,173 @@
import { useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query'
import { useAuthStore } from '../store/auth'
import { brandingApi } from '../api/branding'
export default function TelegramRedirect() {
const { t } = useTranslation()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const { loginWithTelegram, isAuthenticated, isLoading: authLoading } = useAuthStore()
const [status, setStatus] = useState<'loading' | 'success' | 'error' | 'not-telegram'>('loading')
const [errorMessage, setErrorMessage] = useState('')
// Get branding for nice display
const { data: branding } = useQuery({
queryKey: ['branding'],
queryFn: brandingApi.getBranding,
staleTime: 60000,
})
const appName = branding ? branding.name : (import.meta.env.VITE_APP_NAME || 'VPN')
const logoLetter = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V'
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null
// Get redirect target from URL params
const redirectTo = searchParams.get('redirect') || '/'
useEffect(() => {
// If already authenticated, redirect immediately
if (isAuthenticated && !authLoading) {
setStatus('success')
setTimeout(() => navigate(redirectTo), 500)
return
}
const initTelegram = async () => {
// Check if running in Telegram WebApp
const tg = window.Telegram?.WebApp
if (!tg?.initData) {
// Not in Telegram, show message and redirect to login
setStatus('not-telegram')
setTimeout(() => navigate('/login'), 2000)
return
}
// Initialize Telegram WebApp
tg.ready()
tg.expand()
// Set theme colors if available
if (tg.themeParams) {
document.documentElement.style.setProperty('--tg-theme-bg-color', tg.themeParams.bg_color || '#1a1a2e')
document.documentElement.style.setProperty('--tg-theme-text-color', tg.themeParams.text_color || '#ffffff')
}
try {
await loginWithTelegram(tg.initData)
setStatus('success')
// Small delay for nice UX
setTimeout(() => {
navigate(redirectTo)
}, 800)
} catch (err: unknown) {
console.error('Telegram auth failed:', err)
const error = err as { response?: { data?: { detail?: string } } }
setErrorMessage(error.response?.data?.detail || t('auth.telegramRequired'))
setStatus('error')
}
}
// Small delay to show loading screen
setTimeout(initTelegram, 300)
}, [loginWithTelegram, navigate, isAuthenticated, authLoading, redirectTo, t])
// Handle retry
const handleRetry = () => {
setStatus('loading')
setErrorMessage('')
window.location.reload()
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
{/* Background */}
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
<div className="fixed inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-accent-500/10 via-transparent to-transparent" />
<div className="relative text-center max-w-sm w-full">
{/* Logo */}
<div className="mx-auto w-20 h-20 rounded-2xl bg-gradient-to-br from-accent-400 to-accent-600 flex items-center justify-center mb-6 shadow-lg shadow-accent-500/30 overflow-hidden">
{branding?.has_custom_logo && logoUrl ? (
<img src={logoUrl} alt={appName} className="w-full h-full object-cover" />
) : (
<span className="text-white font-bold text-3xl">{logoLetter}</span>
)}
</div>
<h1 className="text-2xl font-bold text-dark-50 mb-2">{appName}</h1>
{/* Loading State */}
{status === 'loading' && (
<div className="mt-8">
<div className="w-10 h-10 border-3 border-accent-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-dark-400">{t('auth.authenticating')}</p>
<p className="text-sm text-dark-500 mt-2">{t('common.loading')}</p>
</div>
)}
{/* Success State */}
{status === 'success' && (
<div className="mt-8">
<div className="w-16 h-16 rounded-full bg-success-500/20 flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-success-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</div>
<p className="text-dark-200">{t('auth.loginSuccess')}</p>
<p className="text-sm text-dark-500 mt-2">Перенаправление...</p>
</div>
)}
{/* Error State */}
{status === 'error' && (
<div className="mt-8">
<div className="w-16 h-16 rounded-full bg-error-500/20 flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-error-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<p className="text-dark-200 mb-2">{t('auth.loginFailed')}</p>
<p className="text-sm text-error-400 mb-6">{errorMessage}</p>
<div className="flex flex-col gap-3">
<button onClick={handleRetry} className="btn-primary w-full">
{t('auth.tryAgain')}
</button>
<button onClick={() => navigate('/login')} className="btn-secondary w-full">
Войти другим способом
</button>
</div>
</div>
)}
{/* Not in Telegram State */}
{status === 'not-telegram' && (
<div className="mt-8">
<div className="w-16 h-16 rounded-full bg-warning-500/20 flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-warning-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
</div>
<p className="text-dark-200 mb-2">Откройте в Telegram</p>
<p className="text-sm text-dark-400 mb-6">
Для автоматического входа откройте это приложение через бота в Telegram
</p>
<p className="text-sm text-dark-500">Перенаправление на страницу входа...</p>
</div>
)}
{/* Telegram branding */}
<div className="mt-12 flex items-center justify-center gap-2 text-dark-600">
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
</svg>
<span className="text-xs">Telegram Mini App</span>
</div>
</div>
</div>
)
}

82
src/pages/VerifyEmail.tsx Normal file
View File

@@ -0,0 +1,82 @@
import { useEffect, useState } from 'react'
import { useSearchParams, Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { authApi } from '../api/auth'
import LanguageSwitcher from '../components/LanguageSwitcher'
export default function VerifyEmail() {
const { t } = useTranslation()
const [searchParams] = useSearchParams()
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
const [error, setError] = useState('')
useEffect(() => {
const token = searchParams.get('token')
if (!token) {
setStatus('error')
setError(t('common.error'))
return
}
const verify = async () => {
try {
await authApi.verifyEmail(token)
setStatus('success')
} catch (err: unknown) {
setStatus('error')
const error = err as { response?: { data?: { detail?: string } } }
setError(error.response?.data?.detail || t('emailVerification.failed'))
}
}
verify()
}, [searchParams, t])
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-8 px-4 sm:py-12">
{/* Language switcher in corner */}
<div className="fixed top-4 right-4 z-50">
<LanguageSwitcher />
</div>
<div className="max-w-md w-full text-center">
{status === 'loading' && (
<div>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
<h2 className="text-lg sm:text-xl font-semibold text-gray-900">{t('emailVerification.verifying')}</h2>
<p className="text-sm sm:text-base text-gray-500 mt-2">{t('emailVerification.pleaseWait')}</p>
</div>
)}
{status === 'success' && (
<div>
<div className="text-green-500 text-5xl sm:text-6xl mb-4"></div>
<h2 className="text-lg sm:text-xl font-semibold text-gray-900">{t('emailVerification.success')}</h2>
<p className="text-sm sm:text-base text-gray-500 mt-2">
{t('emailVerification.successMessage')}
</p>
<div className="mt-6">
<Link to="/login" className="btn-primary">
{t('emailVerification.goToLogin')}
</Link>
</div>
</div>
)}
{status === 'error' && (
<div>
<div className="text-red-500 text-5xl sm:text-6xl mb-4"></div>
<h2 className="text-lg sm:text-xl font-semibold text-gray-900">{t('emailVerification.failed')}</h2>
<p className="text-sm sm:text-base text-gray-500 mt-2">{error}</p>
<div className="mt-6">
<Link to="/login" className="btn-secondary">
{t('emailVerification.goToLogin')}
</Link>
</div>
</div>
)}
</div>
</div>
)
}

749
src/pages/Wheel.tsx Normal file
View File

@@ -0,0 +1,749 @@
import { useState, useCallback, useMemo, useEffect, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { wheelApi, type SpinResult, type SpinHistoryItem } from '../api/wheel'
import FortuneWheel from '../components/wheel/FortuneWheel'
import { useCurrency } from '../hooks/useCurrency'
// Icons
const StarIcon = () => (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
)
const CalendarIcon = () => (
<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.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
</svg>
)
const HistoryIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)
const CloseIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)
const SparklesIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
</svg>
)
const TrophyIcon = () => (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 18.75h-9m9 0a3 3 0 013 3h-15a3 3 0 013-3m9 0v-3.375c0-.621-.503-1.125-1.125-1.125h-.871M7.5 18.75v-3.375c0-.621.504-1.125 1.125-1.125h.872m5.007 0H9.497m5.007 0a7.454 7.454 0 01-.982-3.172M9.497 14.25a7.454 7.454 0 00.981-3.172M5.25 4.236c-.982.143-1.954.317-2.916.52A6.003 6.003 0 007.73 9.728M5.25 4.236V4.5c0 2.108.966 3.99 2.48 5.228M5.25 4.236V2.721C7.456 2.41 9.71 2.25 12 2.25c2.291 0 4.545.16 6.75.47v1.516M7.73 9.728a6.726 6.726 0 002.748 1.35m8.272-6.842V4.5c0 2.108-.966 3.99-2.48 5.228m2.48-5.492a46.32 46.32 0 012.916.52 6.003 6.003 0 01-5.395 4.972m0 0a6.726 6.726 0 01-2.749 1.35m0 0a6.772 6.772 0 01-3.044 0" />
</svg>
)
export default function Wheel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { formatAmount, currencySymbol } = useCurrency()
const [isSpinning, setIsSpinning] = useState(false)
const [targetRotation, setTargetRotation] = useState<number | null>(null)
const [spinResult, setSpinResult] = useState<SpinResult | null>(null)
const [showResultModal, setShowResultModal] = useState(false)
const [paymentType, setPaymentType] = useState<'telegram_stars' | 'subscription_days'>('telegram_stars')
const [showHistory, setShowHistory] = useState(false)
const [isPayingStars, setIsPayingStars] = useState(false)
const isTelegramMiniApp = useMemo(() => {
// Check if we're in Telegram Mini App environment
const webApp = window.Telegram?.WebApp
return !!(webApp && typeof webApp.initData === 'string')
}, [])
const { data: config, isLoading, error } = useQuery({
queryKey: ['wheel-config'],
queryFn: wheelApi.getConfig,
})
const { data: history } = useQuery({
queryKey: ['wheel-history'],
queryFn: () => wheelApi.getHistory(1, 10),
enabled: showHistory,
})
// Function to poll for new spin result after Stars payment
const pollForSpinResult = useCallback(async (maxAttempts = 15, delayMs = 800) => {
// Wait a bit before first poll to give the bot time to process the payment
await new Promise(resolve => setTimeout(resolve, 1500))
// Get current history to find the latest spin ID
let historyBefore
try {
historyBefore = await wheelApi.getHistory(1, 1)
} catch {
historyBefore = { items: [], total: 0 }
}
const lastSpinIdBefore = historyBefore.items.length > 0 ? historyBefore.items[0].id : 0
for (let attempt = 0; attempt < maxAttempts; attempt++) {
await new Promise(resolve => setTimeout(resolve, delayMs))
try {
const historyAfter = await wheelApi.getHistory(1, 1)
// Check if we have a new spin (either new item or higher ID)
if (historyAfter.items.length > 0) {
const latestSpin = historyAfter.items[0]
// If we had no spins before, or this spin has a higher ID
if (lastSpinIdBefore === 0 || latestSpin.id > lastSpinIdBefore) {
// Found a new spin! Return it as SpinResult
return {
success: true,
prize_id: latestSpin.id,
prize_type: latestSpin.prize_type,
prize_value: latestSpin.prize_value,
prize_display_name: latestSpin.prize_display_name,
emoji: latestSpin.emoji,
color: latestSpin.color,
rotation_degrees: 0, // Not needed for result display
message: latestSpin.prize_type === 'nothing'
? t('wheel.noPrize')
: `${t('wheel.youWon')} ${latestSpin.prize_display_name}!`,
promocode: null, // Promocode is sent to bot chat
error: null,
} as SpinResult
}
}
} catch {
// Continue polling on error
}
}
// Timeout - couldn't find new spin
return null
}, [t])
// Ref to store pending Stars payment result
const pendingStarsResultRef = useRef<SpinResult | null>(null)
const isStarsSpinRef = useRef(false)
const starsInvoiceMutation = useMutation({
mutationFn: wheelApi.createStarsInvoice,
onSuccess: (data) => {
if (window.Telegram?.WebApp?.openInvoice) {
window.Telegram.WebApp.openInvoice(data.invoice_url, async (status) => {
if (status === 'paid') {
// Mark this as a Stars spin so handleSpinComplete knows to use the pending result
isStarsSpinRef.current = true
pendingStarsResultRef.current = null
// Payment done - reset paying state immediately
setIsPayingStars(false)
// Start spinning animation (5 seconds duration in FortuneWheel)
setIsSpinning(true)
setTargetRotation(360 * 5 + Math.random() * 360)
// Poll for the result in the background - don't await here!
// The result will be stored and shown when animation completes
pollForSpinResult().then((result) => {
queryClient.invalidateQueries({ queryKey: ['wheel-config'] })
queryClient.invalidateQueries({ queryKey: ['wheel-history'] })
if (result) {
pendingStarsResultRef.current = result
} else {
// Fallback: couldn't get result
pendingStarsResultRef.current = {
success: true,
prize_id: null,
prize_type: null,
prize_value: 0,
prize_display_name: '',
emoji: '🎰',
color: '#8B5CF6',
rotation_degrees: 0,
message: t('wheel.starsPaymentSuccessCheckHistory'),
promocode: null,
error: null,
}
}
}).catch(() => {
// Error polling, show generic success
pendingStarsResultRef.current = {
success: true,
prize_id: null,
prize_type: null,
prize_value: 0,
prize_display_name: '',
emoji: '🎰',
color: '#8B5CF6',
rotation_degrees: 0,
message: t('wheel.starsPaymentSuccessCheckHistory'),
promocode: null,
error: null,
}
})
} else if (status !== 'cancelled') {
setIsPayingStars(false)
setSpinResult({
success: false,
prize_id: null,
prize_type: null,
prize_value: 0,
prize_display_name: '',
emoji: '😔',
color: '#EF4444',
rotation_degrees: 0,
message: t('wheel.starsPaymentFailed'),
promocode: null,
error: 'payment_failed',
})
setShowResultModal(true)
} else {
setIsPayingStars(false)
}
})
} else {
// openInvoice not available - show error
setIsPayingStars(false)
setSpinResult({
success: false,
prize_id: null,
prize_type: null,
prize_value: 0,
prize_display_name: '',
emoji: '😔',
color: '#EF4444',
rotation_degrees: 0,
message: t('wheel.errors.starsNotAvailable'),
promocode: null,
error: 'stars_not_available',
})
setShowResultModal(true)
}
},
onError: () => {
setIsPayingStars(false)
setSpinResult({
success: false,
prize_id: null,
prize_type: null,
prize_value: 0,
prize_display_name: '',
emoji: '😔',
color: '#EF4444',
rotation_degrees: 0,
message: t('wheel.errors.networkError'),
promocode: null,
error: 'network_error',
})
setShowResultModal(true)
},
})
const handleDirectStarsPay = () => {
setIsPayingStars(true)
starsInvoiceMutation.mutate()
}
const spinMutation = useMutation({
mutationFn: () => wheelApi.spin(paymentType),
onSuccess: (result) => {
if (result.success) {
setTargetRotation(result.rotation_degrees)
setSpinResult(result)
} else {
setIsSpinning(false)
setSpinResult(result)
setShowResultModal(true)
}
},
onError: () => {
setIsSpinning(false)
setSpinResult({
success: false,
message: t('wheel.errors.networkError'),
error: 'network_error',
prize_id: null,
prize_type: null,
prize_value: 0,
prize_display_name: '',
emoji: '',
color: '',
rotation_degrees: 0,
promocode: null,
})
setShowResultModal(true)
},
})
const handleSpin = () => {
if (!config?.can_spin || isSpinning) return
setIsSpinning(true)
spinMutation.mutate()
}
const handleSpinComplete = useCallback(() => {
setIsSpinning(false)
// Check if this was a Stars payment spin
if (isStarsSpinRef.current) {
isStarsSpinRef.current = false
// Use the pending result from polling, or show a fallback
if (pendingStarsResultRef.current) {
setSpinResult(pendingStarsResultRef.current)
pendingStarsResultRef.current = null
} else {
// Polling still in progress or failed - show fallback
setSpinResult({
success: true,
prize_id: null,
prize_type: null,
prize_value: 0,
prize_display_name: '',
emoji: '🎰',
color: '#8B5CF6',
rotation_degrees: 0,
message: t('wheel.starsPaymentSuccessCheckHistory'),
promocode: null,
error: null,
})
}
}
setShowResultModal(true)
queryClient.invalidateQueries({ queryKey: ['wheel-config'] })
queryClient.invalidateQueries({ queryKey: ['wheel-history'] })
}, [queryClient, t])
const closeResultModal = () => {
setShowResultModal(false)
setSpinResult(null)
setTargetRotation(null)
}
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="relative">
<div className="w-16 h-16 border-4 border-purple-500/30 border-t-purple-500 rounded-full animate-spin" />
<div className="absolute inset-0 flex items-center justify-center">
<SparklesIcon />
</div>
</div>
</div>
)
}
if (error || !config) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
<div className="w-20 h-20 rounded-full bg-red-500/10 flex items-center justify-center">
<span className="text-4xl">😔</span>
</div>
<p className="text-dark-400 text-lg">{t('wheel.errors.loadFailed')}</p>
</div>
)
}
if (!config.is_enabled) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-6">
<div className="w-24 h-24 rounded-full bg-dark-800 flex items-center justify-center">
<span className="text-5xl">🎡</span>
</div>
<div className="text-center">
<h1 className="text-2xl font-bold text-dark-100 mb-2">{t('wheel.title')}</h1>
<p className="text-dark-400">{t('wheel.disabled')}</p>
</div>
</div>
)
}
const starsEnabled = config.spin_cost_stars_enabled && config.spin_cost_stars
const daysEnabled = config.spin_cost_days_enabled && config.spin_cost_days
const canPayBalance = starsEnabled && config.can_pay_stars // For web: pay with internal balance
const canPayDays = daysEnabled && config.can_pay_days
// Auto-select payment type based on availability
useEffect(() => {
if (isTelegramMiniApp) {
// In Mini App: prefer days if available, Stars payment is separate button
if (canPayDays) {
setPaymentType('subscription_days')
}
} else {
// In Web: prefer balance (Stars converted to rubles), fallback to days
if (canPayBalance) {
setPaymentType('telegram_stars')
} else if (canPayDays) {
setPaymentType('subscription_days')
}
}
}, [canPayBalance, canPayDays, isTelegramMiniApp])
return (
<div className="animate-fade-in pb-8">
{/* Hero Header */}
<div className="relative mb-8 overflow-hidden rounded-2xl bg-gradient-to-br from-purple-600/20 via-indigo-600/20 to-blue-600/20 p-6 sm:p-8">
{/* Background decorations */}
<div className="absolute top-0 right-0 w-64 h-64 bg-purple-500/10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
<div className="absolute bottom-0 left-0 w-48 h-48 bg-blue-500/10 rounded-full blur-3xl translate-y-1/2 -translate-x-1/2" />
<div className="relative flex items-center justify-between">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-gradient-to-br from-purple-500 to-indigo-600 rounded-xl shadow-lg shadow-purple-500/25">
<TrophyIcon />
</div>
<h1 className="text-2xl sm:text-3xl font-bold text-white">
{t('wheel.title')}
</h1>
</div>
{config.daily_limit > 0 && (
<div className="flex items-center gap-2 text-dark-300">
<span>{t('wheel.spinsRemaining')}:</span>
<span className="px-3 py-1 bg-white/10 rounded-full font-bold text-purple-300">
{Math.max(0, config.daily_limit - config.user_spins_today)} / {config.daily_limit}
</span>
</div>
)}
</div>
<button
onClick={() => setShowHistory(!showHistory)}
className={`p-3 rounded-xl transition-all ${
showHistory
? 'bg-purple-500/20 text-purple-300 ring-2 ring-purple-500/50'
: 'bg-white/5 text-dark-300 hover:bg-white/10 hover:text-white'
}`}
>
<HistoryIcon />
</button>
</div>
</div>
{/* Main Content Grid */}
<div className="grid lg:grid-cols-[1fr,320px] gap-6">
{/* Wheel Section */}
<div className="relative">
{/* Wheel Container with glow background */}
<div className="relative p-6 sm:p-8 rounded-2xl bg-gradient-to-b from-dark-800/80 to-dark-900/80 backdrop-blur-sm border border-dark-700/50">
{/* Background glow */}
<div
className="absolute inset-0 rounded-2xl opacity-50 pointer-events-none"
style={{
background: 'radial-gradient(circle at center, rgba(139, 92, 246, 0.15) 0%, transparent 70%)',
}}
/>
{/* Wheel */}
<div className="relative">
<FortuneWheel
prizes={config.prizes}
isSpinning={isSpinning}
targetRotation={targetRotation}
onSpinComplete={handleSpinComplete}
/>
</div>
{/* Spin Button */}
<div className="mt-8 space-y-4">
{/* Payment Options - Different for Web vs Mini App */}
{isTelegramMiniApp ? (
// Mini App: Show Stars button (direct Telegram payment) and Days button
<div className="space-y-3">
{/* Direct Stars payment via Telegram */}
{starsEnabled && (
<button
onClick={handleDirectStarsPay}
disabled={isSpinning || isPayingStars || (config.daily_limit > 0 && config.user_spins_today >= config.daily_limit)}
className="w-full p-4 rounded-xl bg-gradient-to-r from-yellow-500 to-orange-500 text-black font-bold flex items-center justify-center gap-3 shadow-lg shadow-yellow-500/25 hover:shadow-yellow-500/40 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<StarIcon />
{isPayingStars ? t('wheel.processingPayment') : `${t('wheel.payWithStars')} (${config.spin_cost_stars} ⭐)`}
</button>
)}
{/* Days payment option */}
{daysEnabled && (
<button
onClick={() => canPayDays && setPaymentType('subscription_days')}
disabled={isSpinning || !canPayDays}
className={`w-full p-4 rounded-xl border-2 transition-all flex items-center justify-center gap-3 ${
paymentType === 'subscription_days' && canPayDays
? 'border-blue-500/50 bg-gradient-to-br from-blue-500/20 to-cyan-500/10 text-blue-400'
: !canPayDays
? 'border-dark-700 text-dark-500 opacity-50 cursor-not-allowed'
: 'border-dark-700 text-dark-300 hover:border-dark-600 hover:bg-dark-800'
}`}
>
<CalendarIcon />
<span className="font-semibold">
{t('wheel.payWithDays')} ({config.spin_cost_days} {config.spin_cost_days === 1 ? t('wheel.day') : t('wheel.days')})
</span>
</button>
)}
</div>
) : (
// Web: Show Balance button (Stars converted to rubles) and Days button
(starsEnabled || daysEnabled) && (
<div className="flex gap-3">
{starsEnabled && (
<button
onClick={() => canPayBalance && setPaymentType('telegram_stars')}
disabled={isSpinning || !canPayBalance}
className={`flex-1 p-4 rounded-xl border-2 transition-all flex flex-col items-center justify-center gap-1 ${
paymentType === 'telegram_stars' && canPayBalance
? 'border-accent-500/50 bg-gradient-to-br from-accent-500/20 to-blue-500/10 text-accent-400'
: !canPayBalance
? 'border-dark-700 text-dark-500 opacity-50 cursor-not-allowed'
: 'border-dark-700 text-dark-300 hover:border-dark-600 hover:bg-dark-800'
}`}
>
<span className="font-semibold">{formatAmount(config.required_balance_kopeks / 100)} {currencySymbol}</span>
<span className="text-xs opacity-70">({config.spin_cost_stars} )</span>
</button>
)}
{daysEnabled && (
<button
onClick={() => canPayDays && setPaymentType('subscription_days')}
disabled={isSpinning || !canPayDays}
className={`flex-1 p-4 rounded-xl border-2 transition-all flex items-center justify-center gap-3 ${
paymentType === 'subscription_days' && canPayDays
? 'border-blue-500/50 bg-gradient-to-br from-blue-500/20 to-cyan-500/10 text-blue-400'
: !canPayDays
? 'border-dark-700 text-dark-500 opacity-50 cursor-not-allowed'
: 'border-dark-700 text-dark-300 hover:border-dark-600 hover:bg-dark-800'
}`}
>
<CalendarIcon />
<span className="font-semibold">
{config.spin_cost_days} {config.spin_cost_days === 1 ? t('wheel.day') : t('wheel.days')}
</span>
</button>
)}
</div>
)
)}
{/* Main Spin Button - Show for web always, for mini app only if days available */}
{(!isTelegramMiniApp || canPayDays) && (
<button
onClick={handleSpin}
disabled={!config.can_spin || isSpinning || isPayingStars || (isTelegramMiniApp && !canPayDays)}
className={`w-full py-5 rounded-2xl text-xl font-bold transition-all relative overflow-hidden group ${
!config.can_spin || isSpinning || isPayingStars || (isTelegramMiniApp && !canPayDays)
? 'bg-dark-700 text-dark-500 cursor-not-allowed'
: 'bg-gradient-to-r from-purple-600 via-indigo-600 to-purple-600 text-white shadow-xl shadow-purple-500/30 hover:shadow-purple-500/50 hover:scale-[1.02] active:scale-[0.98]'
}`}
style={{
backgroundSize: '200% 100%',
animation: !isSpinning && config.can_spin ? 'shimmer 3s linear infinite' : 'none',
}}
>
{/* Button glow effect */}
{!isSpinning && config.can_spin && (
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-1000" />
)}
{isSpinning ? (
<span className="flex items-center justify-center gap-3">
<div className="w-6 h-6 border-3 border-white/30 border-t-white rounded-full animate-spin" />
{t('wheel.spinning')}
</span>
) : (
<span className="flex items-center justify-center gap-2">
<SparklesIcon />
{t('wheel.spin')}
</span>
)}
</button>
)}
{/* Cannot spin hint */}
{!config.can_spin && !isSpinning && (
<div className="text-center p-4 rounded-xl bg-dark-800/50 border border-dark-700">
{config.can_spin_reason === 'daily_limit_reached' ? (
<p className="text-dark-400">{t('wheel.errors.dailyLimitReached')}</p>
) : config.can_spin_reason === 'insufficient_balance' ? (
<div className="space-y-1">
<p className="text-warning-400 font-medium">{t('wheel.errors.insufficientBalance')}</p>
<p className="text-dark-500 text-sm">
{t('wheel.errors.topUpRequired')} ({formatAmount((config.required_balance_kopeks - config.user_balance_kopeks) / 100)} {currencySymbol})
</p>
</div>
) : (
<p className="text-dark-400">{t('wheel.errors.cannotSpin')}</p>
)}
</div>
)}
</div>
</div>
</div>
{/* History Sidebar */}
{showHistory && (
<div className="lg:sticky lg:top-24 h-fit">
<div className="rounded-2xl bg-dark-800/80 backdrop-blur-sm border border-dark-700/50 overflow-hidden">
<div className="p-4 border-b border-dark-700 flex items-center justify-between">
<h3 className="font-semibold text-dark-100 flex items-center gap-2">
<HistoryIcon />
{t('wheel.recentSpins')}
</h3>
<button
onClick={() => setShowHistory(false)}
className="lg:hidden p-1 text-dark-400 hover:text-dark-200"
>
<CloseIcon />
</button>
</div>
{history && history.items.length > 0 ? (
<div className="max-h-[400px] overflow-y-auto">
{history.items.map((item: SpinHistoryItem, index: number) => (
<div
key={item.id}
className={`flex items-center gap-3 p-4 ${
index !== history.items.length - 1 ? 'border-b border-dark-700/50' : ''
}`}
>
<div
className="w-12 h-12 rounded-xl flex items-center justify-center text-2xl"
style={{ backgroundColor: `${item.color}20` }}
>
{item.emoji}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-dark-100 truncate">
{item.prize_display_name}
</div>
<div className="text-xs text-dark-500">
{new Date(item.created_at).toLocaleDateString()}
</div>
</div>
<div className="text-xs text-dark-400 whitespace-nowrap">
-{item.payment_type === 'telegram_stars'
? `${item.payment_amount}`
: `${item.payment_amount}${t('wheel.days').charAt(0)}`}
</div>
</div>
))}
</div>
) : (
<div className="p-8 text-center text-dark-500">
<div className="text-4xl mb-2">🎰</div>
{t('wheel.noHistory')}
</div>
)}
</div>
</div>
)}
</div>
{/* Result Modal */}
{showResultModal && spinResult && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-fade-in">
<div
className={`relative w-full max-w-md rounded-3xl p-8 text-center overflow-hidden ${
spinResult.success
? 'bg-gradient-to-br from-purple-900/90 via-indigo-900/90 to-purple-900/90'
: 'bg-gradient-to-br from-dark-800 via-dark-900 to-dark-800'
} border ${spinResult.success ? 'border-purple-500/30' : 'border-dark-700'}`}
>
{/* Decorative elements */}
{spinResult.success && (
<>
<div className="absolute top-0 left-0 w-32 h-32 bg-purple-500/20 rounded-full blur-3xl" />
<div className="absolute bottom-0 right-0 w-32 h-32 bg-indigo-500/20 rounded-full blur-3xl" />
{/* Confetti effect */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{Array.from({ length: 20 }).map((_, i) => (
<div
key={i}
className="absolute w-2 h-2 rounded-full animate-bounce"
style={{
background: ['#fbbf24', '#a855f7', '#3b82f6', '#10b981', '#f43f5e'][i % 5],
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
animationDelay: `${Math.random() * 2}s`,
animationDuration: `${1 + Math.random()}s`,
}}
/>
))}
</div>
</>
)}
{/* Close button */}
<button
onClick={closeResultModal}
className="absolute top-4 right-4 p-2 text-dark-400 hover:text-dark-200 transition-colors rounded-lg hover:bg-white/5"
>
<CloseIcon />
</button>
{/* Content */}
<div className="relative space-y-6">
{/* Prize icon */}
<div
className="w-28 h-28 mx-auto rounded-full flex items-center justify-center text-6xl shadow-2xl"
style={{
background: spinResult.success
? `linear-gradient(135deg, ${spinResult.color || '#8B5CF6'}40, ${spinResult.color || '#8B5CF6'}20)`
: 'rgba(239, 68, 68, 0.1)',
boxShadow: spinResult.success
? `0 0 60px ${spinResult.color || '#8B5CF6'}40`
: 'none',
}}
>
{spinResult.success ? (spinResult.emoji || '🎉') : '😔'}
</div>
{/* Title */}
<h2 className="text-3xl font-bold text-white">
{spinResult.success
? spinResult.prize_type === 'nothing'
? t('wheel.noLuck')
: t('wheel.congratulations')
: t('wheel.oops')}
</h2>
{/* Message */}
<p className="text-lg text-dark-200">{spinResult.message}</p>
{/* Promocode if won */}
{spinResult.promocode && (
<div className="p-5 bg-gradient-to-r from-purple-500/20 via-indigo-500/20 to-purple-500/20 border border-purple-500/30 rounded-2xl">
<p className="text-sm text-purple-300 mb-3">{t('wheel.yourPromoCode')}</p>
<p className="text-2xl font-mono font-bold text-white select-all tracking-wider">
{spinResult.promocode}
</p>
</div>
)}
{/* Close button */}
<button
onClick={closeResultModal}
className="w-full py-4 rounded-xl bg-white/10 hover:bg-white/20 text-white font-semibold transition-all"
>
{t('wheel.close')}
</button>
</div>
</div>
</div>
)}
<style>{`
@keyframes shimmer {
0% { background-position: 200% 50%; }
100% { background-position: -200% 50%; }
}
`}</style>
</div>
)
}