mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
Add files via upload
This commit is contained in:
629
src/pages/AdminApps.tsx
Normal file
629
src/pages/AdminApps.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
502
src/pages/AdminDashboard.tsx
Normal file
502
src/pages/AdminDashboard.tsx
Normal 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
155
src/pages/AdminPanel.tsx
Normal 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
470
src/pages/AdminServers.tsx
Normal 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
1442
src/pages/AdminSettings.tsx
Normal file
File diff suppressed because it is too large
Load Diff
893
src/pages/AdminTariffs.tsx
Normal file
893
src/pages/AdminTariffs.tsx
Normal 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
385
src/pages/AdminTickets.tsx
Normal 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
650
src/pages/AdminWheel.tsx
Normal 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
292
src/pages/Balance.tsx
Normal 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
239
src/pages/Contests.tsx
Normal 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
460
src/pages/Dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
315
src/pages/DeepLinkRedirect.tsx
Normal file
315
src/pages/DeepLinkRedirect.tsx
Normal 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
295
src/pages/Info.tsx
Normal 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
267
src/pages/Login.tsx
Normal 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
243
src/pages/Polls.tsx
Normal 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
414
src/pages/Profile.tsx
Normal 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
248
src/pages/Referral.tsx
Normal 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
1732
src/pages/Subscription.tsx
Normal file
File diff suppressed because it is too large
Load Diff
682
src/pages/Support.tsx
Normal file
682
src/pages/Support.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
87
src/pages/TelegramCallback.tsx
Normal file
87
src/pages/TelegramCallback.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
173
src/pages/TelegramRedirect.tsx
Normal file
173
src/pages/TelegramRedirect.tsx
Normal 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
82
src/pages/VerifyEmail.tsx
Normal 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
749
src/pages/Wheel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user