refactor: migrate to eslint flat config and format codebase with prettier

- Remove legacy .eslintrc.cjs and .eslintignore
- Add eslint.config.js with flat config, security rules (no-eval, no-implied-eval, no-new-func, no-script-url)
- Add .prettierrc and .prettierignore
- Format entire codebase with prettier
This commit is contained in:
c0mrade
2026-01-27 17:37:31 +03:00
parent 111ccc4e7a
commit bc90ba3779
133 changed files with 19972 additions and 15523 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,81 +1,115 @@
import { useState, useRef, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import { useState, useRef, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import {
adminBroadcastsApi,
Broadcast,
BroadcastFilter,
TariffFilter,
BroadcastCreateRequest,
} from '../api/adminBroadcasts'
} from '../api/adminBroadcasts';
// Icons
const BackIcon = () => (
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
)
);
const BroadcastIcon = () => (
<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.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46" />
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"
/>
</svg>
)
);
const PlusIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg className="h-5 w-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 XIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)
);
const RefreshIcon = () => (
<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 className="h-4 w-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>
)
);
const StopIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 7.5A2.25 2.25 0 017.5 5.25h9a2.25 2.25 0 012.25 2.25v9a2.25 2.25 0 01-2.25 2.25h-9a2.25 2.25 0 01-2.25-2.25v-9z" />
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5.25 7.5A2.25 2.25 0 017.5 5.25h9a2.25 2.25 0 012.25 2.25v9a2.25 2.25 0 01-2.25 2.25h-9a2.25 2.25 0 01-2.25-2.25v-9z"
/>
</svg>
)
);
const PhotoIcon = () => (
<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 className="h-5 w-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 VideoIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z"
/>
</svg>
)
);
const DocumentIcon = () => (
<svg className="w-5 h-5" 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.25m2.25 0H5.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 className="h-5 w-5" 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.25m2.25 0H5.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 UsersIcon = () => (
<svg className="w-5 h-5" 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 className="h-5 w-5" 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>
)
);
const ChevronDownIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg className="h-4 w-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>
)
);
// Status badge component
function StatusBadge({ status }: { status: string }) {
@@ -87,13 +121,13 @@ function StatusBadge({ status }: { status: string }) {
failed: { bg: 'bg-red-500/20', text: 'text-red-400', label: 'Ошибка' },
cancelled: { bg: 'bg-gray-500/20', text: 'text-gray-400', label: 'Отменено' },
cancelling: { bg: 'bg-yellow-500/20', text: 'text-yellow-400', label: 'Отменяется' },
}
const config = statusConfig[status] || statusConfig.queued
};
const config = statusConfig[status] || statusConfig.queued;
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${config.bg} ${config.text}`}>
<span className={`rounded-full px-2 py-1 text-xs font-medium ${config.bg} ${config.text}`}>
{config.label}
</span>
)
);
}
// Filter labels
@@ -105,201 +139,203 @@ const FILTER_GROUP_LABELS: Record<string, string> = {
activity: 'По активности',
source: 'По источнику',
tariff: 'По тарифу',
}
};
// Create broadcast modal
interface CreateModalProps {
onClose: () => void
onSuccess: () => void
onClose: () => void;
onSuccess: () => void;
}
function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const { t } = useTranslation();
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const [target, setTarget] = useState('')
const [messageText, setMessageText] = useState('')
const [selectedButtons, setSelectedButtons] = useState<string[]>(['home'])
const [mediaFile, setMediaFile] = useState<File | null>(null)
const [mediaType, setMediaType] = useState<'photo' | 'video' | 'document'>('photo')
const [mediaPreview, setMediaPreview] = useState<string | null>(null)
const [uploadedFileId, setUploadedFileId] = useState<string | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [showFilters, setShowFilters] = useState(false)
const [target, setTarget] = useState('');
const [messageText, setMessageText] = useState('');
const [selectedButtons, setSelectedButtons] = useState<string[]>(['home']);
const [mediaFile, setMediaFile] = useState<File | null>(null);
const [mediaType, setMediaType] = useState<'photo' | 'video' | 'document'>('photo');
const [mediaPreview, setMediaPreview] = useState<string | null>(null);
const [uploadedFileId, setUploadedFileId] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [showFilters, setShowFilters] = useState(false);
// Fetch filters
const { data: filtersData, isLoading: filtersLoading } = useQuery({
queryKey: ['admin', 'broadcasts', 'filters'],
queryFn: adminBroadcastsApi.getFilters,
})
});
// Fetch buttons
const { data: buttonsData } = useQuery({
queryKey: ['admin', 'broadcasts', 'buttons'],
queryFn: adminBroadcastsApi.getButtons,
})
});
// Preview mutation
const previewMutation = useMutation({
mutationFn: adminBroadcastsApi.preview,
})
});
// Create mutation
const createMutation = useMutation({
mutationFn: adminBroadcastsApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] })
onSuccess()
onClose()
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] });
onSuccess();
onClose();
},
})
});
// Group filters
const groupedFilters = useMemo(() => {
if (!filtersData) return {}
const groups: Record<string, (BroadcastFilter | TariffFilter)[]> = {}
if (!filtersData) return {};
const groups: Record<string, (BroadcastFilter | TariffFilter)[]> = {};
// Basic filters
filtersData.filters.forEach(f => {
const group = f.group || 'basic'
if (!groups[group]) groups[group] = []
groups[group].push(f)
})
filtersData.filters.forEach((f) => {
const group = f.group || 'basic';
if (!groups[group]) groups[group] = [];
groups[group].push(f);
});
// Tariff filters
if (filtersData.tariff_filters.length > 0) {
groups['tariff'] = filtersData.tariff_filters
groups['tariff'] = filtersData.tariff_filters;
}
// Custom filters
filtersData.custom_filters.forEach(f => {
const group = f.group || 'custom'
if (!groups[group]) groups[group] = []
groups[group].push(f)
})
filtersData.custom_filters.forEach((f) => {
const group = f.group || 'custom';
if (!groups[group]) groups[group] = [];
groups[group].push(f);
});
return groups
}, [filtersData])
return groups;
}, [filtersData]);
// Selected filter info
const selectedFilter = useMemo(() => {
if (!target || !filtersData) return null
if (!target || !filtersData) return null;
const all = [
...filtersData.filters,
...filtersData.tariff_filters,
...filtersData.custom_filters,
]
return all.find(f => f.key === target)
}, [target, filtersData])
];
return all.find((f) => f.key === target);
}, [target, filtersData]);
// Handle file selection
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const file = e.target.files?.[0];
if (!file) return;
setMediaFile(file)
setMediaFile(file);
// Determine media type
if (file.type.startsWith('image/')) {
setMediaType('photo')
setMediaPreview(URL.createObjectURL(file))
setMediaType('photo');
setMediaPreview(URL.createObjectURL(file));
} else if (file.type.startsWith('video/')) {
setMediaType('video')
setMediaPreview(null)
setMediaType('video');
setMediaPreview(null);
} else {
setMediaType('document')
setMediaPreview(null)
setMediaType('document');
setMediaPreview(null);
}
// Upload file
setIsUploading(true)
setIsUploading(true);
try {
const result = await adminBroadcastsApi.uploadMedia(file, mediaType)
setUploadedFileId(result.file_id)
const result = await adminBroadcastsApi.uploadMedia(file, mediaType);
setUploadedFileId(result.file_id);
} catch (err) {
console.error('Upload failed:', err)
setMediaFile(null)
setMediaPreview(null)
console.error('Upload failed:', err);
setMediaFile(null);
setMediaPreview(null);
} finally {
setIsUploading(false)
setIsUploading(false);
}
}
};
// Remove media
const handleRemoveMedia = () => {
setMediaFile(null)
setMediaPreview(null)
setUploadedFileId(null)
setMediaFile(null);
setMediaPreview(null);
setUploadedFileId(null);
if (fileInputRef.current) {
fileInputRef.current.value = ''
fileInputRef.current.value = '';
}
}
};
// Toggle button
const toggleButton = (key: string) => {
setSelectedButtons(prev =>
prev.includes(key) ? prev.filter(b => b !== key) : [...prev, key]
)
}
setSelectedButtons((prev) =>
prev.includes(key) ? prev.filter((b) => b !== key) : [...prev, key],
);
};
// Submit
const handleSubmit = () => {
if (!target || !messageText.trim()) return
if (!target || !messageText.trim()) return;
const data: BroadcastCreateRequest = {
target,
message_text: messageText,
selected_buttons: selectedButtons,
}
};
if (uploadedFileId) {
data.media = {
type: mediaType,
file_id: uploadedFileId,
}
};
}
createMutation.mutate(data)
}
createMutation.mutate(data);
};
const recipientsCount = previewMutation.data?.count ?? selectedFilter?.count ?? null
const recipientsCount = previewMutation.data?.count ?? selectedFilter?.count ?? null;
return (
<div className="fixed inset-0 flex items-center justify-center z-50 p-4 overflow-y-auto">
<div className="bg-dark-800 rounded-xl w-full max-w-2xl my-4">
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4">
<div className="my-4 w-full max-w-2xl rounded-xl bg-dark-800">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-700">
<div className="flex items-center justify-between border-b border-dark-700 p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-accent-500/20 rounded-lg text-accent-400">
<div className="rounded-lg bg-accent-500/20 p-2 text-accent-400">
<BroadcastIcon />
</div>
<h2 className="text-lg font-semibold text-dark-100">{t('admin.broadcasts.create')}</h2>
</div>
<button onClick={onClose} className="p-2 hover:bg-dark-700 rounded-lg transition-colors">
<button onClick={onClose} className="rounded-lg p-2 transition-colors hover:bg-dark-700">
<XIcon />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4 max-h-[70vh] overflow-y-auto">
<div className="max-h-[70vh] space-y-4 overflow-y-auto p-4">
{/* Filter selection */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.broadcasts.selectFilter')}
</label>
<div className="relative">
<button
onClick={() => setShowFilters(!showFilters)}
className="w-full p-3 bg-dark-700 rounded-lg text-left flex items-center justify-between hover:bg-dark-600 transition-colors"
className="flex w-full items-center justify-between rounded-lg bg-dark-700 p-3 text-left transition-colors hover:bg-dark-600"
>
<div className="flex items-center gap-2">
<UsersIcon />
<span className={selectedFilter ? 'text-dark-100' : 'text-dark-400'}>
{selectedFilter ? selectedFilter.label : t('admin.broadcasts.selectFilterPlaceholder')}
{selectedFilter
? selectedFilter.label
: t('admin.broadcasts.selectFilterPlaceholder')}
</span>
{recipientsCount !== null && (
<span className="px-2 py-0.5 bg-accent-500/20 text-accent-400 rounded-full text-xs">
<span className="rounded-full bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
{recipientsCount} {t('admin.broadcasts.recipients')}
</span>
)}
@@ -308,24 +344,24 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
</button>
{showFilters && (
<div className="absolute top-full left-0 right-0 mt-1 bg-dark-700 rounded-lg shadow-xl z-10 max-h-64 overflow-y-auto">
<div className="absolute left-0 right-0 top-full z-10 mt-1 max-h-64 overflow-y-auto rounded-lg bg-dark-700 shadow-xl">
{filtersLoading ? (
<div className="p-4 text-center text-dark-400">Loading...</div>
) : (
Object.entries(groupedFilters).map(([group, filters]) => (
<div key={group}>
<div className="px-3 py-2 text-xs font-medium text-dark-400 bg-dark-800 sticky top-0">
<div className="sticky top-0 bg-dark-800 px-3 py-2 text-xs font-medium text-dark-400">
{FILTER_GROUP_LABELS[group] || group}
</div>
{filters.map(filter => (
{filters.map((filter) => (
<button
key={filter.key}
onClick={() => {
setTarget(filter.key)
setShowFilters(false)
previewMutation.mutate(filter.key)
setTarget(filter.key);
setShowFilters(false);
previewMutation.mutate(filter.key);
}}
className={`w-full px-3 py-2 text-left hover:bg-dark-600 transition-colors flex items-center justify-between ${
className={`flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-dark-600 ${
target === filter.key ? 'bg-accent-500/20' : ''
}`}
>
@@ -345,29 +381,27 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
{/* Message text */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.broadcasts.messageText')}
</label>
<textarea
value={messageText}
onChange={e => setMessageText(e.target.value)}
onChange={(e) => setMessageText(e.target.value)}
placeholder={t('admin.broadcasts.messageTextPlaceholder')}
rows={5}
maxLength={4000}
className="w-full p-3 bg-dark-700 rounded-lg text-dark-100 placeholder-dark-400 resize-none focus:outline-none focus:ring-2 focus:ring-accent-500"
className="w-full resize-none rounded-lg bg-dark-700 p-3 text-dark-100 placeholder-dark-400 focus:outline-none focus:ring-2 focus:ring-accent-500"
/>
<div className="text-xs text-dark-400 mt-1 text-right">
{messageText.length}/4000
</div>
<div className="mt-1 text-right text-xs text-dark-400">{messageText.length}/4000</div>
</div>
{/* Media upload */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.broadcasts.media')}
</label>
{mediaFile ? (
<div className="p-3 bg-dark-700 rounded-lg">
<div className="rounded-lg bg-dark-700 p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{mediaType === 'photo' && <PhotoIcon />}
@@ -382,7 +416,7 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
</div>
<button
onClick={handleRemoveMedia}
className="p-2 hover:bg-dark-600 rounded-lg text-dark-400 hover:text-red-400"
className="rounded-lg p-2 text-dark-400 hover:bg-dark-600 hover:text-red-400"
disabled={isUploading}
>
<XIcon />
@@ -396,7 +430,9 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
/>
)}
{isUploading && (
<div className="mt-2 text-sm text-accent-400">{t('admin.broadcasts.uploading')}</div>
<div className="mt-2 text-sm text-accent-400">
{t('admin.broadcasts.uploading')}
</div>
)}
</div>
) : (
@@ -410,7 +446,7 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
/>
<button
onClick={() => fileInputRef.current?.click()}
className="flex-1 p-3 bg-dark-700 rounded-lg text-dark-400 hover:bg-dark-600 hover:text-dark-100 transition-colors flex items-center justify-center gap-2"
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-dark-700 p-3 text-dark-400 transition-colors hover:bg-dark-600 hover:text-dark-100"
>
<PhotoIcon />
<span>{t('admin.broadcasts.addMedia')}</span>
@@ -421,15 +457,15 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
{/* Buttons selection */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.broadcasts.buttons')}
</label>
<div className="flex flex-wrap gap-2">
{buttonsData?.buttons.map(button => (
{buttonsData?.buttons.map((button) => (
<button
key={button.key}
onClick={() => toggleButton(button.key)}
className={`px-3 py-2 rounded-lg text-sm transition-colors ${
className={`rounded-lg px-3 py-2 text-sm transition-colors ${
selectedButtons.includes(button.key)
? 'bg-accent-500 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600'
@@ -443,76 +479,74 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
</div>
{/* Footer */}
<div className="flex items-center justify-between p-4 border-t border-dark-700">
<div className="flex items-center justify-between border-t border-dark-700 p-4">
<div className="text-sm text-dark-400">
{recipientsCount !== null && (
<span>
{t('admin.broadcasts.willBeSent')}: <strong className="text-accent-400">{recipientsCount}</strong> {t('admin.broadcasts.users')}
{t('admin.broadcasts.willBeSent')}:{' '}
<strong className="text-accent-400">{recipientsCount}</strong>{' '}
{t('admin.broadcasts.users')}
</span>
)}
</div>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 bg-dark-700 rounded-lg text-dark-300 hover:bg-dark-600 transition-colors"
className="rounded-lg bg-dark-700 px-4 py-2 text-dark-300 transition-colors hover:bg-dark-600"
>
{t('common.cancel')}
</button>
<button
onClick={handleSubmit}
disabled={!target || !messageText.trim() || createMutation.isPending || isUploading}
className="px-4 py-2 bg-accent-500 rounded-lg text-white hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{createMutation.isPending ? (
<RefreshIcon />
) : (
<BroadcastIcon />
)}
{createMutation.isPending ? <RefreshIcon /> : <BroadcastIcon />}
{t('admin.broadcasts.send')}
</button>
</div>
</div>
</div>
</div>
)
);
}
// Broadcast detail modal
interface DetailModalProps {
broadcast: Broadcast
onClose: () => void
onStop: () => void
isStopping: boolean
broadcast: Broadcast;
onClose: () => void;
onStop: () => void;
isStopping: boolean;
}
function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: DetailModalProps) {
const { t } = useTranslation()
const isRunning = ['queued', 'in_progress', 'cancelling'].includes(broadcast.status)
const { t } = useTranslation();
const isRunning = ['queued', 'in_progress', 'cancelling'].includes(broadcast.status);
return (
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
<div className="bg-dark-800 rounded-xl w-full max-w-lg">
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="w-full max-w-lg rounded-xl bg-dark-800">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-700">
<div className="flex items-center justify-between border-b border-dark-700 p-4">
<div className="flex items-center gap-3">
<StatusBadge status={broadcast.status} />
<span className="text-dark-400">#{broadcast.id}</span>
</div>
<button onClick={onClose} className="p-2 hover:bg-dark-700 rounded-lg transition-colors">
<button onClick={onClose} className="rounded-lg p-2 transition-colors hover:bg-dark-700">
<XIcon />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4">
<div className="space-y-4 p-4">
{/* Progress */}
{isRunning && (
<div>
<div className="flex justify-between text-sm mb-1">
<div className="mb-1 flex justify-between text-sm">
<span className="text-dark-400">{t('admin.broadcasts.progress')}</span>
<span className="text-dark-100">{broadcast.progress_percent.toFixed(1)}%</span>
</div>
<div className="h-2 bg-dark-700 rounded-full overflow-hidden">
<div className="h-2 overflow-hidden rounded-full bg-dark-700">
<div
className="h-full bg-accent-500 transition-all duration-300"
style={{ width: `${broadcast.progress_percent}%` }}
@@ -523,15 +557,15 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
{/* Stats */}
<div className="grid grid-cols-3 gap-3">
<div className="p-3 bg-dark-700 rounded-lg text-center">
<div className="rounded-lg bg-dark-700 p-3 text-center">
<p className="text-2xl font-bold text-dark-100">{broadcast.total_count}</p>
<p className="text-xs text-dark-400">{t('admin.broadcasts.total')}</p>
</div>
<div className="p-3 bg-dark-700 rounded-lg text-center">
<div className="rounded-lg bg-dark-700 p-3 text-center">
<p className="text-2xl font-bold text-green-400">{broadcast.sent_count}</p>
<p className="text-xs text-dark-400">{t('admin.broadcasts.sent')}</p>
</div>
<div className="p-3 bg-dark-700 rounded-lg text-center">
<div className="rounded-lg bg-dark-700 p-3 text-center">
<p className="text-2xl font-bold text-red-400">{broadcast.failed_count}</p>
<p className="text-xs text-dark-400">{t('admin.broadcasts.failed')}</p>
</div>
@@ -539,14 +573,14 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
{/* Target */}
<div>
<p className="text-sm text-dark-400 mb-1">{t('admin.broadcasts.filter')}</p>
<p className="mb-1 text-sm text-dark-400">{t('admin.broadcasts.filter')}</p>
<p className="text-dark-100">{broadcast.target_type}</p>
</div>
{/* Message */}
<div>
<p className="text-sm text-dark-400 mb-1">{t('admin.broadcasts.message')}</p>
<div className="p-3 bg-dark-700 rounded-lg text-dark-100 whitespace-pre-wrap text-sm max-h-40 overflow-y-auto">
<p className="mb-1 text-sm text-dark-400">{t('admin.broadcasts.message')}</p>
<div className="max-h-40 overflow-y-auto whitespace-pre-wrap rounded-lg bg-dark-700 p-3 text-sm text-dark-100">
{broadcast.message_text}
</div>
</div>
@@ -554,7 +588,7 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
{/* Media */}
{broadcast.has_media && (
<div>
<p className="text-sm text-dark-400 mb-1">{t('admin.broadcasts.media')}</p>
<p className="mb-1 text-sm text-dark-400">{t('admin.broadcasts.media')}</p>
<div className="flex items-center gap-2 text-dark-100">
{broadcast.media_type === 'photo' && <PhotoIcon />}
{broadcast.media_type === 'video' && <VideoIcon />}
@@ -573,11 +607,11 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
{/* Footer */}
{isRunning && broadcast.status !== 'cancelling' && (
<div className="p-4 border-t border-dark-700">
<div className="border-t border-dark-700 p-4">
<button
onClick={onStop}
disabled={isStopping}
className="w-full py-2 bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30 transition-colors flex items-center justify-center gap-2 disabled:opacity-50"
className="flex w-full items-center justify-center gap-2 rounded-lg bg-red-500/20 py-2 text-red-400 transition-colors hover:bg-red-500/30 disabled:opacity-50"
>
<StopIcon />
{t('admin.broadcasts.stop')}
@@ -586,48 +620,48 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
)}
</div>
</div>
)
);
}
// Main component
export default function AdminBroadcasts() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { t } = useTranslation();
const queryClient = useQueryClient();
const [showCreateModal, setShowCreateModal] = useState(false)
const [selectedBroadcast, setSelectedBroadcast] = useState<Broadcast | null>(null)
const [page, setPage] = useState(0)
const limit = 20
const [showCreateModal, setShowCreateModal] = useState(false);
const [selectedBroadcast, setSelectedBroadcast] = useState<Broadcast | null>(null);
const [page, setPage] = useState(0);
const limit = 20;
// Fetch broadcasts
const { data, isLoading, refetch } = useQuery({
queryKey: ['admin', 'broadcasts', 'list', page],
queryFn: () => adminBroadcastsApi.list(limit, page * limit),
refetchInterval: 5000, // Auto refresh every 5s
})
});
// Stop mutation
const stopMutation = useMutation({
mutationFn: adminBroadcastsApi.stop,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] })
setSelectedBroadcast(null)
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] });
setSelectedBroadcast(null);
},
})
});
const broadcasts = data?.items || []
const total = data?.total || 0
const totalPages = Math.ceil(total / limit)
const broadcasts = data?.items || [];
const total = data?.total || 0;
const totalPages = Math.ceil(total / limit);
return (
<div className="min-h-screen bg-dark-900 p-4 pb-20 md:pb-4">
<div className="max-w-4xl mx-auto">
<div className="mx-auto max-w-4xl">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link
to="/admin"
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 hover:border-dark-600 transition-colors"
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
>
<BackIcon />
</Link>
@@ -639,13 +673,13 @@ export default function AdminBroadcasts() {
<div className="flex gap-2">
<button
onClick={() => refetch()}
className="p-2 bg-dark-800 rounded-lg text-dark-400 hover:text-dark-100 transition-colors"
className="rounded-lg bg-dark-800 p-2 text-dark-400 transition-colors hover:text-dark-100"
>
<RefreshIcon />
</button>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-accent-500 rounded-lg text-white hover:bg-accent-600 transition-colors flex items-center gap-2"
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
>
<PlusIcon />
<span className="hidden sm:inline">{t('admin.broadcasts.create')}</span>
@@ -654,7 +688,7 @@ export default function AdminBroadcasts() {
</div>
{/* Broadcasts list */}
<div className="bg-dark-800 rounded-xl overflow-hidden">
<div className="overflow-hidden rounded-xl bg-dark-800">
{isLoading ? (
<div className="p-8 text-center text-dark-400">
<RefreshIcon />
@@ -667,15 +701,15 @@ export default function AdminBroadcasts() {
</div>
) : (
<div className="divide-y divide-dark-700">
{broadcasts.map(broadcast => (
{broadcasts.map((broadcast) => (
<button
key={broadcast.id}
onClick={() => setSelectedBroadcast(broadcast)}
className="w-full p-4 hover:bg-dark-700/50 transition-colors text-left"
className="w-full p-4 text-left transition-colors hover:bg-dark-700/50"
>
<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">
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<StatusBadge status={broadcast.status} />
<span className="text-xs text-dark-400">#{broadcast.id}</span>
{broadcast.has_media && (
@@ -686,10 +720,8 @@ export default function AdminBroadcasts() {
</span>
)}
</div>
<p className="text-dark-100 text-sm truncate">
{broadcast.message_text}
</p>
<div className="flex items-center gap-4 mt-2 text-xs text-dark-400">
<p className="truncate text-sm text-dark-100">{broadcast.message_text}</p>
<div className="mt-2 flex items-center gap-4 text-xs text-dark-400">
<span>{broadcast.target_type}</span>
<span>
{broadcast.sent_count}/{broadcast.total_count}
@@ -699,13 +731,13 @@ export default function AdminBroadcasts() {
</div>
{['queued', 'in_progress'].includes(broadcast.status) && (
<div className="w-16">
<div className="h-1.5 bg-dark-600 rounded-full overflow-hidden">
<div className="h-1.5 overflow-hidden rounded-full bg-dark-600">
<div
className="h-full bg-accent-500"
style={{ width: `${broadcast.progress_percent}%` }}
/>
</div>
<p className="text-xs text-dark-400 text-center mt-1">
<p className="mt-1 text-center text-xs text-dark-400">
{broadcast.progress_percent.toFixed(0)}%
</p>
</div>
@@ -718,11 +750,11 @@ export default function AdminBroadcasts() {
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 p-4 border-t border-dark-700">
<div className="flex items-center justify-center gap-2 border-t border-dark-700 p-4">
<button
onClick={() => setPage(p => Math.max(0, p - 1))}
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="px-3 py-1 bg-dark-700 rounded-lg text-dark-300 hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
className="rounded-lg bg-dark-700 px-3 py-1 text-dark-300 hover:bg-dark-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{t('common.prev')}
</button>
@@ -730,9 +762,9 @@ export default function AdminBroadcasts() {
{page + 1} / {totalPages}
</span>
<button
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="px-3 py-1 bg-dark-700 rounded-lg text-dark-300 hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
className="rounded-lg bg-dark-700 px-3 py-1 text-dark-300 hover:bg-dark-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{t('common.next')}
</button>
@@ -746,7 +778,7 @@ export default function AdminBroadcasts() {
<CreateBroadcastModal
onClose={() => setShowCreateModal(false)}
onSuccess={() => {
refetch()
refetch();
}}
/>
)}
@@ -760,5 +792,5 @@ export default function AdminBroadcasts() {
/>
)}
</div>
)
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,71 +1,93 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import { useState, useCallback, useRef, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import {
adminEmailTemplatesApi,
EmailTemplateType,
EmailTemplateDetail,
EmailTemplateLanguageData,
} from '../api/adminEmailTemplates'
} from '../api/adminEmailTemplates';
// Icons
const BackIcon = () => (
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
)
);
const MailIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
)
);
const SaveIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg className="h-4 w-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 EyeIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</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 className="h-4 w-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 ResetIcon = () => (
<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 className="h-4 w-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>
)
);
const XIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)
);
const LANG_LABELS: Record<string, string> = {
ru: 'RU',
en: 'EN',
zh: 'ZH',
ua: 'UA',
}
};
const LANG_FULL_LABELS: Record<string, string> = {
ru: 'Русский',
en: 'English',
zh: '中文',
ua: 'Українська',
}
};
// ============ Template List View ============
@@ -74,31 +96,31 @@ function TemplateCard({
currentLang,
onClick,
}: {
template: EmailTemplateType
currentLang: string
onClick: () => void
template: EmailTemplateType;
currentLang: string;
onClick: () => void;
}) {
const label = template.label[currentLang] || template.label['en'] || template.type
const description = template.description[currentLang] || template.description['en'] || ''
const customCount = Object.values(template.languages).filter(l => l.has_custom).length
const label = template.label[currentLang] || template.label['en'] || template.type;
const description = template.description[currentLang] || template.description['en'] || '';
const customCount = Object.values(template.languages).filter((l) => l.has_custom).length;
return (
<button
onClick={onClick}
className="w-full text-left p-3 sm:p-4 bg-dark-800 rounded-xl border border-dark-700 hover:border-accent-500/50 transition-all duration-200 group"
className="group w-full rounded-xl border border-dark-700 bg-dark-800 p-3 text-left transition-all duration-200 hover:border-accent-500/50 sm:p-4"
>
<div className="flex items-start justify-between gap-2 sm:gap-3">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-dark-100 group-hover:text-accent-400 transition-colors truncate">
<div className="min-w-0 flex-1">
<h3 className="truncate text-sm font-medium text-dark-100 transition-colors group-hover:text-accent-400">
{label}
</h3>
<p className="text-xs text-dark-400 mt-1 line-clamp-2">{description}</p>
<p className="mt-1 line-clamp-2 text-xs text-dark-400">{description}</p>
</div>
<div className="flex items-center gap-1 sm:gap-1.5 flex-shrink-0 mt-0.5">
<div className="mt-0.5 flex flex-shrink-0 items-center gap-1 sm:gap-1.5">
{Object.entries(template.languages).map(([lang, status]) => (
<span
key={lang}
className={`inline-flex items-center justify-center w-6 sm:w-7 h-5 rounded text-2xs font-medium ${
className={`inline-flex h-5 w-6 items-center justify-center rounded text-2xs font-medium sm:w-7 ${
status.has_custom
? 'bg-accent-500/20 text-accent-400 ring-1 ring-accent-500/30'
: 'bg-dark-700 text-dark-400'
@@ -112,14 +134,14 @@ function TemplateCard({
</div>
{customCount > 0 && (
<div className="mt-2">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-2xs bg-accent-500/10 text-accent-400">
<span className="w-1.5 h-1.5 rounded-full bg-accent-400" />
<span className="inline-flex items-center gap-1 rounded-full bg-accent-500/10 px-2 py-0.5 text-2xs text-accent-400">
<span className="h-1.5 w-1.5 rounded-full bg-accent-400" />
{customCount} custom
</span>
</div>
)}
</button>
)
);
}
// ============ Template Editor ============
@@ -129,225 +151,249 @@ function TemplateEditor({
onClose,
currentLang: interfaceLang,
}: {
detail: EmailTemplateDetail
onClose: () => void
currentLang: string
detail: EmailTemplateDetail;
onClose: () => void;
currentLang: string;
}) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [activeLang, setActiveLang] = useState('ru')
const [editSubject, setEditSubject] = useState('')
const [editBody, setEditBody] = useState('')
const [isDirty, setIsDirty] = useState(false)
const [showPreview, setShowPreview] = useState(false)
const [previewHtml, setPreviewHtml] = useState('')
const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const iframeRef = useRef<HTMLIFrameElement>(null)
const { t } = useTranslation();
const queryClient = useQueryClient();
const [activeLang, setActiveLang] = useState('ru');
const [editSubject, setEditSubject] = useState('');
const [editBody, setEditBody] = useState('');
const [isDirty, setIsDirty] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const [previewHtml, setPreviewHtml] = useState('');
const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const iframeRef = useRef<HTMLIFrameElement>(null);
const langData: EmailTemplateLanguageData | undefined = detail.languages[activeLang]
const langData: EmailTemplateLanguageData | undefined = detail.languages[activeLang];
// Load data for current language
useEffect(() => {
if (langData) {
setEditSubject(langData.subject)
setEditBody(langData.is_default ? langData.body_html : langData.body_html)
setIsDirty(false)
setEditSubject(langData.subject);
setEditBody(langData.is_default ? langData.body_html : langData.body_html);
setIsDirty(false);
}
}, [activeLang, langData])
}, [activeLang, langData]);
const showToast = useCallback((type: 'success' | 'error', message: string) => {
setToast({ type, message })
setTimeout(() => setToast(null), 3000)
}, [])
setToast({ type, message });
setTimeout(() => setToast(null), 3000);
}, []);
// Extract body content from full HTML (strip base template wrapper)
const extractBodyContent = useCallback((html: string): string => {
// If it's wrapped in the base template, extract just the content div
const contentMatch = html.match(/<div class="content">\s*([\s\S]*?)\s*<\/div>\s*<div class="footer">/)
const contentMatch = html.match(
/<div class="content">\s*([\s\S]*?)\s*<\/div>\s*<div class="footer">/,
);
if (contentMatch) {
return contentMatch[1].trim()
return contentMatch[1].trim();
}
return html
}, [])
return html;
}, []);
// When langData changes (e.g., after refetch), update the body content
useEffect(() => {
if (langData) {
if (langData.is_default) {
// For default templates, extract just the content portion
setEditBody(extractBodyContent(langData.body_html))
setEditBody(extractBodyContent(langData.body_html));
} else {
setEditBody(langData.body_html)
setEditBody(langData.body_html);
}
setEditSubject(langData.subject)
setIsDirty(false)
setEditSubject(langData.subject);
setIsDirty(false);
}
}, [activeLang, langData, extractBodyContent])
}, [activeLang, langData, extractBodyContent]);
// Save mutation
const saveMutation = useMutation({
mutationFn: () => adminEmailTemplatesApi.updateTemplate(detail.notification_type, activeLang, {
subject: editSubject,
body_html: editBody,
}),
mutationFn: () =>
adminEmailTemplatesApi.updateTemplate(detail.notification_type, activeLang, {
subject: editSubject,
body_html: editBody,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'email-templates'] })
queryClient.invalidateQueries({ queryKey: ['admin', 'email-template', detail.notification_type] })
setIsDirty(false)
showToast('success', t('admin.emailTemplates.saved', 'Template saved'))
queryClient.invalidateQueries({ queryKey: ['admin', 'email-templates'] });
queryClient.invalidateQueries({
queryKey: ['admin', 'email-template', detail.notification_type],
});
setIsDirty(false);
showToast('success', t('admin.emailTemplates.saved', 'Template saved'));
},
onError: () => {
showToast('error', t('common.error', 'Error'))
showToast('error', t('common.error', 'Error'));
},
})
});
// Reset mutation
const resetMutation = useMutation({
mutationFn: () => adminEmailTemplatesApi.deleteTemplate(detail.notification_type, activeLang),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'email-templates'] })
queryClient.invalidateQueries({ queryKey: ['admin', 'email-template', detail.notification_type] })
setIsDirty(false)
showToast('success', t('admin.emailTemplates.resetted', 'Template reset to default'))
queryClient.invalidateQueries({ queryKey: ['admin', 'email-templates'] });
queryClient.invalidateQueries({
queryKey: ['admin', 'email-template', detail.notification_type],
});
setIsDirty(false);
showToast('success', t('admin.emailTemplates.resetted', 'Template reset to default'));
},
onError: () => {
showToast('error', t('common.error', 'Error'))
showToast('error', t('common.error', 'Error'));
},
})
});
// Preview mutation
const previewMutation = useMutation({
mutationFn: () => adminEmailTemplatesApi.previewTemplate(detail.notification_type, {
language: activeLang,
subject: editSubject,
body_html: editBody,
}),
mutationFn: () =>
adminEmailTemplatesApi.previewTemplate(detail.notification_type, {
language: activeLang,
subject: editSubject,
body_html: editBody,
}),
onSuccess: (data) => {
setPreviewHtml(data.body_html)
setShowPreview(true)
setPreviewHtml(data.body_html);
setShowPreview(true);
},
onError: () => {
showToast('error', t('common.error', 'Error'))
showToast('error', t('common.error', 'Error'));
},
})
});
// Send test mutation
const testMutation = useMutation({
mutationFn: () => adminEmailTemplatesApi.sendTestEmail(detail.notification_type, {
language: activeLang,
}),
mutationFn: () =>
adminEmailTemplatesApi.sendTestEmail(detail.notification_type, {
language: activeLang,
}),
onSuccess: (data) => {
showToast('success', `${t('admin.emailTemplates.testSent', 'Test email sent')}${data.sent_to}`)
showToast(
'success',
`${t('admin.emailTemplates.testSent', 'Test email sent')}${data.sent_to}`,
);
},
onError: () => {
showToast('error', t('common.error', 'Error'))
showToast('error', t('common.error', 'Error'));
},
})
});
// Write preview HTML into iframe
useEffect(() => {
if (showPreview && iframeRef.current && previewHtml) {
const doc = iframeRef.current.contentDocument
const doc = iframeRef.current.contentDocument;
if (doc) {
doc.open()
doc.write(previewHtml)
doc.close()
doc.open();
doc.write(previewHtml);
doc.close();
}
}
}, [showPreview, previewHtml])
}, [showPreview, previewHtml]);
const handleSubjectChange = (value: string) => {
setEditSubject(value)
setIsDirty(true)
}
setEditSubject(value);
setIsDirty(true);
};
const handleBodyChange = (value: string) => {
setEditBody(value)
setIsDirty(true)
}
setEditBody(value);
setIsDirty(true);
};
const label = detail.label[interfaceLang] || detail.label['en'] || detail.notification_type
const label = detail.label[interfaceLang] || detail.label['en'] || detail.notification_type;
return (
<div className="space-y-3 sm:space-y-4">
{/* Header */}
<div className="flex items-start sm:items-center justify-between gap-2">
<div className="flex items-start sm:items-center gap-2 sm:gap-3 min-w-0">
<button onClick={onClose} className="p-1 rounded-lg hover:bg-dark-700 transition-colors flex-shrink-0 mt-0.5 sm:mt-0">
<div className="flex items-start justify-between gap-2 sm:items-center">
<div className="flex min-w-0 items-start gap-2 sm:items-center sm:gap-3">
<button
onClick={onClose}
className="mt-0.5 flex-shrink-0 rounded-lg p-1 transition-colors hover:bg-dark-700 sm:mt-0"
>
<BackIcon />
</button>
<div className="min-w-0">
<h2 className="text-base sm:text-lg font-semibold text-dark-100 truncate">{label}</h2>
<p className="text-xs text-dark-400 line-clamp-2">
<h2 className="truncate text-base font-semibold text-dark-100 sm:text-lg">{label}</h2>
<p className="line-clamp-2 text-xs text-dark-400">
{detail.description[interfaceLang] || detail.description['en'] || ''}
</p>
</div>
</div>
{langData && !langData.is_default && (
<span className="px-2 sm:px-2.5 py-1 rounded-full text-2xs sm:text-xs font-medium bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/25 flex-shrink-0">
<span className="flex-shrink-0 rounded-full bg-accent-500/15 px-2 py-1 text-2xs font-medium text-accent-400 ring-1 ring-accent-500/25 sm:px-2.5 sm:text-xs">
Custom
</span>
)}
</div>
{/* Language tabs */}
<div className="flex items-center gap-1 p-1 bg-dark-900 rounded-lg overflow-x-auto">
{Object.keys(detail.languages).map(lang => {
const isActive = lang === activeLang
const langInfo = detail.languages[lang]
<div className="flex items-center gap-1 overflow-x-auto rounded-lg bg-dark-900 p-1">
{Object.keys(detail.languages).map((lang) => {
const isActive = lang === activeLang;
const langInfo = detail.languages[lang];
return (
<button
key={lang}
onClick={() => {
if (isDirty && !window.confirm(t('admin.emailTemplates.unsavedWarning', 'Unsaved changes will be lost. Continue?'))) return
setActiveLang(lang)
if (
isDirty &&
!window.confirm(
t(
'admin.emailTemplates.unsavedWarning',
'Unsaved changes will be lost. Continue?',
),
)
)
return;
setActiveLang(lang);
}}
className={`flex-1 px-2 sm:px-3 py-2 rounded-md text-xs sm:text-sm font-medium transition-all duration-150 flex items-center justify-center gap-1 sm:gap-1.5 whitespace-nowrap ${
className={`flex flex-1 items-center justify-center gap-1 whitespace-nowrap rounded-md px-2 py-2 text-xs font-medium transition-all duration-150 sm:gap-1.5 sm:px-3 sm:text-sm ${
isActive
? 'bg-dark-700 text-dark-100 shadow-sm'
: 'text-dark-400 hover:text-dark-200 hover:bg-dark-800'
: 'text-dark-400 hover:bg-dark-800 hover:text-dark-200'
}`}
>
<span className="sm:hidden">{LANG_LABELS[lang] || lang}</span>
<span className="hidden sm:inline">{LANG_FULL_LABELS[lang] || lang}</span>
{!langInfo.is_default && (
<span className="w-1.5 h-1.5 rounded-full bg-accent-400 flex-shrink-0" />
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-accent-400" />
)}
</button>
)
);
})}
</div>
{/* Subject */}
<div>
<label className="block text-xs font-medium text-dark-300 mb-1.5">
<label className="mb-1.5 block text-xs font-medium text-dark-300">
{t('admin.emailTemplates.subject', 'Subject')}
</label>
<input
type="text"
value={editSubject}
onChange={e => handleSubjectChange(e.target.value)}
className="w-full px-3 py-2.5 bg-dark-900 border border-dark-600 rounded-lg text-sm text-dark-100 placeholder-dark-500 focus:outline-none focus:ring-1 focus:ring-accent-500 focus:border-accent-500 transition-colors"
onChange={(e) => handleSubjectChange(e.target.value)}
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2.5 text-sm text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500"
placeholder={t('admin.emailTemplates.subjectPlaceholder', 'Email subject line...')}
/>
</div>
{/* Context variables hint */}
{detail.context_vars.length > 0 && (
<div className="p-2.5 sm:p-3 bg-dark-900/60 border border-dark-700 rounded-lg">
<p className="text-xs font-medium text-dark-300 mb-1.5">
<div className="rounded-lg border border-dark-700 bg-dark-900/60 p-2.5 sm:p-3">
<p className="mb-1.5 text-xs font-medium text-dark-300">
{t('admin.emailTemplates.variables', 'Available Variables')}
</p>
<div className="flex flex-wrap gap-1 sm:gap-1.5">
{detail.context_vars.map(v => (
{detail.context_vars.map((v) => (
<code
key={v}
className="px-2 py-0.5 rounded bg-dark-700 text-accent-400 text-xs font-mono cursor-pointer hover:bg-dark-600 transition-colors"
className="cursor-pointer rounded bg-dark-700 px-2 py-0.5 font-mono text-xs text-accent-400 transition-colors hover:bg-dark-600"
title={t('admin.emailTemplates.clickToCopy', 'Click to copy')}
onClick={() => {
navigator.clipboard.writeText(`{${v}}`)
showToast('success', `Copied {${v}}`)
navigator.clipboard.writeText(`{${v}}`);
showToast('success', `Copied {${v}}`);
}}
>
{`{${v}}`}
@@ -359,30 +405,33 @@ function TemplateEditor({
{/* Body HTML editor */}
<div>
<label className="block text-xs font-medium text-dark-300 mb-1.5">
<label className="mb-1.5 block text-xs font-medium text-dark-300">
{t('admin.emailTemplates.body', 'Body (HTML)')}
</label>
<textarea
ref={textareaRef}
value={editBody}
onChange={e => handleBodyChange(e.target.value)}
onChange={(e) => handleBodyChange(e.target.value)}
rows={12}
className="w-full px-3 py-2.5 bg-dark-900 border border-dark-600 rounded-lg text-xs sm:text-sm text-dark-100 placeholder-dark-500 font-mono leading-relaxed focus:outline-none focus:ring-1 focus:ring-accent-500 focus:border-accent-500 transition-colors resize-y min-h-[200px] sm:min-h-[300px]"
className="min-h-[200px] w-full resize-y rounded-lg border border-dark-600 bg-dark-900 px-3 py-2.5 font-mono text-xs leading-relaxed text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500 sm:min-h-[300px] sm:text-sm"
placeholder="<h2>Title</h2><p>Content...</p>"
spellCheck={false}
/>
<p className="text-2xs text-dark-500 mt-1">
{t('admin.emailTemplates.bodyHint', 'HTML content that will be wrapped in the base email template with header and footer.')}
<p className="mt-1 text-2xs text-dark-500">
{t(
'admin.emailTemplates.bodyHint',
'HTML content that will be wrapped in the base email template with header and footer.',
)}
</p>
</div>
{/* Actions */}
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2">
<div className="grid grid-cols-2 sm:flex gap-2">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
<div className="grid grid-cols-2 gap-2 sm:flex">
<button
onClick={() => saveMutation.mutate()}
disabled={!isDirty || saveMutation.isPending}
className="inline-flex items-center justify-center gap-1.5 px-3 sm:px-4 py-2.5 sm:py-2 rounded-lg text-sm font-medium bg-accent-500 text-white hover:bg-accent-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-accent-500 px-3 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-40 sm:px-4 sm:py-2"
>
<SaveIcon />
{saveMutation.isPending ? t('common.loading', 'Loading...') : t('common.save', 'Save')}
@@ -391,35 +440,46 @@ function TemplateEditor({
<button
onClick={() => previewMutation.mutate()}
disabled={previewMutation.isPending}
className="inline-flex items-center justify-center gap-1.5 px-3 sm:px-4 py-2.5 sm:py-2 rounded-lg text-sm font-medium bg-dark-700 text-dark-200 hover:bg-dark-600 disabled:opacity-40 transition-colors"
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-dark-700 px-3 py-2.5 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-600 disabled:opacity-40 sm:px-4 sm:py-2"
>
<EyeIcon />
{t('admin.emailTemplates.preview', 'Preview')}
</button>
</div>
<div className="grid grid-cols-2 sm:flex gap-2">
<div className="grid grid-cols-2 gap-2 sm:flex">
<button
onClick={() => testMutation.mutate()}
disabled={testMutation.isPending}
className="inline-flex items-center justify-center gap-1.5 px-3 sm:px-4 py-2.5 sm:py-2 rounded-lg text-sm font-medium bg-dark-700 text-dark-200 hover:bg-dark-600 disabled:opacity-40 transition-colors"
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-dark-700 px-3 py-2.5 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-600 disabled:opacity-40 sm:px-4 sm:py-2"
>
<SendIcon />
{testMutation.isPending ? t('common.loading', 'Loading...') : t('admin.emailTemplates.sendTest', 'Send Test')}
{testMutation.isPending
? t('common.loading', 'Loading...')
: t('admin.emailTemplates.sendTest', 'Send Test')}
</button>
{langData && !langData.is_default && (
<button
onClick={() => {
if (window.confirm(t('admin.emailTemplates.resetConfirm', 'Reset this template to the default version?'))) {
resetMutation.mutate()
if (
window.confirm(
t(
'admin.emailTemplates.resetConfirm',
'Reset this template to the default version?',
),
)
) {
resetMutation.mutate();
}
}}
disabled={resetMutation.isPending}
className="inline-flex items-center justify-center gap-1.5 px-3 sm:px-4 py-2.5 sm:py-2 rounded-lg text-sm font-medium bg-dark-700 text-warning-400 hover:bg-dark-600 disabled:opacity-40 transition-colors sm:ml-auto"
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-dark-700 px-3 py-2.5 text-sm font-medium text-warning-400 transition-colors hover:bg-dark-600 disabled:opacity-40 sm:ml-auto sm:px-4 sm:py-2"
>
<ResetIcon />
<span className="truncate">{t('admin.emailTemplates.resetDefault', 'Reset to Default')}</span>
<span className="truncate">
{t('admin.emailTemplates.resetDefault', 'Reset to Default')}
</span>
</button>
)}
</div>
@@ -427,26 +487,26 @@ function TemplateEditor({
{/* Toast */}
{toast && (
<div className={`fixed bottom-4 left-4 right-4 sm:left-auto sm:right-6 sm:bottom-6 z-50 px-4 py-3 rounded-xl text-sm font-medium shadow-lg animate-fade-in text-center sm:text-left ${
toast.type === 'success'
? 'bg-emerald-500/90 text-white'
: 'bg-red-500/90 text-white'
}`}>
<div
className={`fixed bottom-4 left-4 right-4 z-50 animate-fade-in rounded-xl px-4 py-3 text-center text-sm font-medium shadow-lg sm:bottom-6 sm:left-auto sm:right-6 sm:text-left ${
toast.type === 'success' ? 'bg-emerald-500/90 text-white' : 'bg-red-500/90 text-white'
}`}
>
{toast.message}
</div>
)}
{/* Preview Modal */}
{showPreview && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center sm:p-4 bg-black/60 backdrop-blur-sm">
<div className="bg-dark-800 rounded-t-2xl sm:rounded-2xl w-full sm:max-w-2xl max-h-[90vh] sm:max-h-[85vh] flex flex-col border border-dark-600 shadow-2xl">
<div className="flex items-center justify-between px-4 sm:px-5 py-3 sm:py-4 border-b border-dark-700">
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-sm sm:items-center sm:p-4">
<div className="flex max-h-[90vh] w-full flex-col rounded-t-2xl border border-dark-600 bg-dark-800 shadow-2xl sm:max-h-[85vh] sm:max-w-2xl sm:rounded-2xl">
<div className="flex items-center justify-between border-b border-dark-700 px-4 py-3 sm:px-5 sm:py-4">
<h3 className="text-base font-semibold text-dark-100">
{t('admin.emailTemplates.preview', 'Preview')}
</h3>
<button
onClick={() => setShowPreview(false)}
className="p-1.5 rounded-lg hover:bg-dark-700 transition-colors"
className="rounded-lg p-1.5 transition-colors hover:bg-dark-700"
>
<XIcon />
</button>
@@ -454,7 +514,7 @@ function TemplateEditor({
<div className="flex-1 overflow-hidden p-1">
<iframe
ref={iframeRef}
className="w-full h-full min-h-[50vh] sm:min-h-[400px] rounded-lg bg-white"
className="h-full min-h-[50vh] w-full rounded-lg bg-white sm:min-h-[400px]"
sandbox="allow-same-origin"
title="Email Preview"
/>
@@ -463,48 +523,48 @@ function TemplateEditor({
</div>
)}
</div>
)
);
}
// ============ Main Page ============
export default function AdminEmailTemplates() {
const { t, i18n } = useTranslation()
const currentLang = i18n.language || 'ru'
const [selectedType, setSelectedType] = useState<string | null>(null)
const { t, i18n } = useTranslation();
const currentLang = i18n.language || 'ru';
const [selectedType, setSelectedType] = useState<string | null>(null);
// Fetch template types list
const { data: typesData, isLoading: typesLoading } = useQuery({
queryKey: ['admin', 'email-templates'],
queryFn: adminEmailTemplatesApi.getTemplateTypes,
})
});
// Fetch detail for selected type
const { data: detailData, isLoading: detailLoading } = useQuery({
queryKey: ['admin', 'email-template', selectedType],
queryFn: () => adminEmailTemplatesApi.getTemplate(selectedType!),
enabled: !!selectedType,
})
});
return (
<div className="max-w-4xl mx-auto px-3 sm:px-4 py-4 sm:py-6 space-y-4 sm:space-y-6">
<div className="mx-auto max-w-4xl space-y-4 px-3 py-4 sm:space-y-6 sm:px-4 sm:py-6">
{/* Page Header */}
<div className="flex items-center gap-2 sm:gap-3">
<Link
to="/admin"
className="p-1.5 sm:p-2 rounded-xl bg-dark-800 hover:bg-dark-700 transition-colors border border-dark-700 flex-shrink-0"
className="flex-shrink-0 rounded-xl border border-dark-700 bg-dark-800 p-1.5 transition-colors hover:bg-dark-700 sm:p-2"
>
<BackIcon />
</Link>
<div className="flex items-center gap-2 sm:gap-2.5 min-w-0">
<div className="p-1.5 sm:p-2 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 text-blue-400 flex-shrink-0">
<div className="flex min-w-0 items-center gap-2 sm:gap-2.5">
<div className="flex-shrink-0 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 p-1.5 text-blue-400 sm:p-2">
<MailIcon />
</div>
<div className="min-w-0">
<h1 className="text-lg sm:text-xl font-bold text-dark-100 truncate">
<h1 className="truncate text-lg font-bold text-dark-100 sm:text-xl">
{t('admin.emailTemplates.title', 'Email Templates')}
</h1>
<p className="text-xs text-dark-400 truncate">
<p className="truncate text-xs text-dark-400">
{t('admin.emailTemplates.description', 'Manage email notification templates')}
</p>
</div>
@@ -524,12 +584,12 @@ export default function AdminEmailTemplates() {
{typesLoading ? (
<div className="space-y-3">
{[...Array(6)].map((_, i) => (
<div key={i} className="h-20 bg-dark-800 rounded-xl animate-pulse" />
<div key={i} className="h-20 animate-pulse rounded-xl bg-dark-800" />
))}
</div>
) : (
<div className="grid gap-2 sm:gap-3 grid-cols-1 sm:grid-cols-2">
{typesData?.items.map(template => (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-3">
{typesData?.items.map((template) => (
<TemplateCard
key={template.type}
template={template}
@@ -545,9 +605,9 @@ export default function AdminEmailTemplates() {
{/* Detail loading overlay */}
{selectedType && detailLoading && (
<div className="flex items-center justify-center py-16">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
)}
</div>
)
);
}

View File

@@ -1,210 +1,298 @@
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
// Group header icons
const AnalyticsGroupIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5m.75-9l3-3 2.148 2.148A12.061 12.061 0 0116.5 7.605" />
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5m.75-9l3-3 2.148 2.148A12.061 12.061 0 0116.5 7.605"
/>
</svg>
)
);
const UsersGroupIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
/>
</svg>
)
);
const TariffsGroupIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
)
);
const MarketingGroupIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46" />
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"
/>
</svg>
)
);
const SystemGroupIcon = () => (
<svg className="w-4 h-4" 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 className="h-4 w-4" 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>
)
);
// Modern icons with consistent styling
const ChartBarIcon = () => (
<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 className="h-5 w-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 BanknotesIcon = () => (
<svg className="w-5 h-5" 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 className="h-5 w-5" 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 UsersIcon = () => (
<svg className="w-5 h-5" 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 className="h-5 w-5" 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 ChatBubbleIcon = () => (
<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.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 className="h-5 w-5" 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>
)
);
const NoSymbolIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/>
</svg>
)
);
const CreditCardIcon = () => (
<svg className="w-5 h-5" 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 className="h-5 w-5" 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 TicketIcon = () => (
<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 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 className="h-5 w-5" 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 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 className="h-5 w-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 MegaphoneIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46" />
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"
/>
</svg>
)
);
const PaperAirplaneIcon = () => (
<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 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 className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<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 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.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 className="h-5 w-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.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 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 className="h-5 w-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"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)
);
const DevicePhoneMobileIcon = () => (
<svg className="w-5 h-5" 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 className="h-5 w-5" 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 ServerStackIcon = () => (
<svg className="w-5 h-5" 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 className="h-5 w-5" 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 EnvelopeIcon = () => (
<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.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
)
);
const CubeTransparentIcon = () => (
<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 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25" />
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25"
/>
</svg>
)
);
const ChevronRightIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg className="h-4 w-4" 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>
)
);
interface AdminItem {
to: string
icon: React.ReactNode
title: string
description: string
to: string;
icon: React.ReactNode;
title: string;
description: string;
}
interface AdminGroup {
id: string
title: string
icon: React.ReactNode
gradient: string
borderColor: string
iconBg: string
iconColor: string
items: AdminItem[]
id: string;
title: string;
icon: React.ReactNode;
gradient: string;
borderColor: string;
iconBg: string;
iconColor: string;
items: AdminItem[];
}
function AdminCard({ to, icon, title, description, iconBg, iconColor }: AdminItem & { iconBg: string; iconColor: string }) {
function AdminCard({
to,
icon,
title,
description,
iconBg,
iconColor,
}: AdminItem & { iconBg: string; iconColor: string }) {
return (
<Link
to={to}
className="group flex items-center gap-3 p-3 rounded-xl bg-dark-800/40 border border-dark-700/50 hover:bg-dark-800/80 hover:border-dark-600 transition-all duration-200"
className="group flex items-center gap-3 rounded-xl border border-dark-700/50 bg-dark-800/40 p-3 transition-all duration-200 hover:border-dark-600 hover:bg-dark-800/80"
>
<div className={`w-10 h-10 rounded-lg ${iconBg} ${iconColor} flex items-center justify-center shrink-0 group-hover:scale-105 transition-transform`}>
<div
className={`h-10 w-10 rounded-lg ${iconBg} ${iconColor} flex shrink-0 items-center justify-center transition-transform group-hover:scale-105`}
>
{icon}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-dark-100 group-hover:text-white transition-colors">{title}</h3>
<p className="text-xs text-dark-500 truncate">{description}</p>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-medium text-dark-100 transition-colors group-hover:text-white">
{title}
</h3>
<p className="truncate text-xs text-dark-500">{description}</p>
</div>
<div className="text-dark-600 group-hover:text-dark-400 transition-colors">
<div className="text-dark-600 transition-colors group-hover:text-dark-400">
<ChevronRightIcon />
</div>
</Link>
)
);
}
function GroupSection({ group }: { group: AdminGroup }) {
return (
<div className="rounded-2xl border border-dark-700/50 overflow-hidden bg-dark-900/30 backdrop-blur">
<div className="overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-900/30 backdrop-blur">
{/* Group Header */}
<div className={`px-4 py-3 bg-gradient-to-r ${group.gradient} border-b ${group.borderColor}`}>
<div className={`bg-gradient-to-r px-4 py-3 ${group.gradient} border-b ${group.borderColor}`}>
<div className="flex items-center gap-2.5">
<div className={`p-1.5 rounded-lg ${group.iconBg} ${group.iconColor}`}>
{group.icon}
</div>
<div className={`rounded-lg p-1.5 ${group.iconBg} ${group.iconColor}`}>{group.icon}</div>
<h2 className="text-sm font-semibold text-dark-100">{group.title}</h2>
</div>
</div>
{/* Group Items */}
<div className="p-2 space-y-1.5">
<div className="space-y-1.5 p-2">
{group.items.map((item) => (
<AdminCard
key={item.to}
{...item}
iconBg={group.iconBg}
iconColor={group.iconColor}
/>
<AdminCard key={item.to} {...item} iconBg={group.iconBg} iconColor={group.iconColor} />
))}
</div>
</div>
)
);
}
export default function AdminPanel() {
const { t } = useTranslation()
const { t } = useTranslation();
const groups: AdminGroup[] = [
{
@@ -364,22 +452,22 @@ export default function AdminPanel() {
},
],
},
]
];
return (
<div className="animate-fade-in space-y-4">
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-dark-100">{t('admin.panel.title')}</h1>
<p className="text-sm text-dark-400 mt-1">{t('admin.panel.subtitle')}</p>
<p className="mt-1 text-sm text-dark-400">{t('admin.panel.subtitle')}</p>
</div>
{/* Groups Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{groups.map((group) => (
<GroupSection key={group.id} group={group} />
))}
</div>
</div>
)
);
}

View File

@@ -1,7 +1,7 @@
import { useState, useCallback, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import { useState, useCallback, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import {
DndContext,
closestCenter,
@@ -11,55 +11,69 @@ import {
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods'
import type { PaymentMethodConfig, PromoGroupSimple } from '../types'
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
import type { PaymentMethodConfig, PromoGroupSimple } from '../types';
// ============ Icons ============
const BackIcon = () => (
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
)
);
const GripIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
)
);
const ChevronRightIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg className="h-5 w-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 CloseIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)
);
const CheckIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg className="h-4 w-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 SaveIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
)
);
// ============ Method icon by type ============
@@ -75,7 +89,7 @@ const METHOD_ICONS: Record<string, string> = {
wata: '\uD83D\uDCA7',
freekassa: '\uD83D\uDCB5',
cloudpayments: '\u2601\uFE0F',
}
};
const METHOD_LABELS: Record<string, string> = {
telegram_stars: 'Telegram Stars',
@@ -89,53 +103,56 @@ const METHOD_LABELS: Record<string, string> = {
wata: 'WATA',
freekassa: 'Freekassa',
cloudpayments: 'CloudPayments',
}
};
// ============ Sortable Card ============
interface SortableCardProps {
config: PaymentMethodConfig
onClick: () => void
config: PaymentMethodConfig;
onClick: () => void;
}
function SortablePaymentCard({ config, onClick }: SortableCardProps) {
const { t } = useTranslation()
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: config.method_id })
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: config.method_id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 50 : undefined,
opacity: isDragging ? 0.85 : 1,
}
};
const displayName = config.display_name || config.default_display_name
const icon = METHOD_ICONS[config.method_id] || '\uD83D\uDCB3'
const displayName = config.display_name || config.default_display_name;
const icon = METHOD_ICONS[config.method_id] || '\uD83D\uDCB3';
// Build condition summary chips
const chips: string[] = []
if (config.user_type_filter === 'telegram') chips.push(t('admin.paymentMethods.userTypeTelegram', 'Telegram'))
if (config.user_type_filter === 'email') chips.push(t('admin.paymentMethods.userTypeEmail', 'Email'))
if (config.first_topup_filter === 'yes') chips.push(t('admin.paymentMethods.firstTopupYes', 'C пополнением'))
if (config.first_topup_filter === 'no') chips.push(t('admin.paymentMethods.firstTopupNo', 'Без пополнения'))
const chips: string[] = [];
if (config.user_type_filter === 'telegram')
chips.push(t('admin.paymentMethods.userTypeTelegram', 'Telegram'));
if (config.user_type_filter === 'email')
chips.push(t('admin.paymentMethods.userTypeEmail', 'Email'));
if (config.first_topup_filter === 'yes')
chips.push(t('admin.paymentMethods.firstTopupYes', 'C пополнением'));
if (config.first_topup_filter === 'no')
chips.push(t('admin.paymentMethods.firstTopupNo', 'Без пополнения'));
if (config.promo_group_filter_mode === 'selected' && config.allowed_promo_group_ids.length > 0) {
chips.push(`${config.allowed_promo_group_ids.length} ${t('admin.paymentMethods.promoGroupsShort', 'групп')}`)
chips.push(
`${config.allowed_promo_group_ids.length} ${t('admin.paymentMethods.promoGroupsShort', 'групп')}`,
);
}
// Count enabled sub-options
let subOptionsInfo = ''
let subOptionsInfo = '';
if (config.available_sub_options && config.sub_options) {
const enabledCount = config.available_sub_options.filter(o => config.sub_options?.[o.id] !== false).length
const totalCount = config.available_sub_options.length
const enabledCount = config.available_sub_options.filter(
(o) => config.sub_options?.[o.id] !== false,
).length;
const totalCount = config.available_sub_options.length;
if (enabledCount < totalCount) {
subOptionsInfo = `${enabledCount}/${totalCount}`
subOptionsInfo = `${enabledCount}/${totalCount}`;
}
}
@@ -143,49 +160,49 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
<div
ref={setNodeRef}
style={style}
className={`group flex items-center gap-3 p-4 rounded-xl border transition-all ${
className={`group flex items-center gap-3 rounded-xl border p-4 transition-all ${
isDragging
? 'bg-dark-700/80 border-accent-500/50 shadow-xl shadow-accent-500/10'
? 'border-accent-500/50 bg-dark-700/80 shadow-xl shadow-accent-500/10'
: config.is_enabled
? 'bg-dark-800/50 border-dark-700/50 hover:border-dark-600'
: 'bg-dark-900/30 border-dark-800/50 opacity-60'
? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
: 'border-dark-800/50 bg-dark-900/30 opacity-60'
}`}
>
{/* Drag handle */}
<button
{...attributes}
{...listeners}
className="flex-shrink-0 p-1.5 rounded-lg text-dark-500 hover:text-dark-300 hover:bg-dark-700/50 cursor-grab active:cursor-grabbing touch-manipulation"
className="flex-shrink-0 cursor-grab touch-manipulation rounded-lg p-1.5 text-dark-500 hover:bg-dark-700/50 hover:text-dark-300 active:cursor-grabbing"
title={t('admin.paymentMethods.dragToReorder', 'Перетащите для изменения порядка')}
>
<GripIcon />
</button>
{/* Method icon */}
<div className="flex-shrink-0 w-10 h-10 rounded-xl bg-dark-700/50 flex items-center justify-center text-xl">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-dark-700/50 text-xl">
{icon}
</div>
{/* Content */}
<div className="flex-1 min-w-0 cursor-pointer" onClick={onClick}>
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-dark-100 truncate">{displayName}</span>
<div className="min-w-0 flex-1 cursor-pointer" onClick={onClick}>
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-semibold text-dark-100">{displayName}</span>
{config.is_enabled ? (
<span className="flex-shrink-0 text-xs px-2 py-0.5 rounded-full bg-success-500/15 text-success-400 border border-success-500/20">
<span className="flex-shrink-0 rounded-full border border-success-500/20 bg-success-500/15 px-2 py-0.5 text-xs text-success-400">
{t('admin.paymentMethods.enabled', 'Вкл')}
</span>
) : (
<span className="flex-shrink-0 text-xs px-2 py-0.5 rounded-full bg-dark-700/50 text-dark-500 border border-dark-700/30">
<span className="flex-shrink-0 rounded-full border border-dark-700/30 bg-dark-700/50 px-2 py-0.5 text-xs text-dark-500">
{t('admin.paymentMethods.disabled', 'Выкл')}
</span>
)}
{!config.is_provider_configured && (
<span className="flex-shrink-0 text-xs px-2 py-0.5 rounded-full bg-warning-500/15 text-warning-400 border border-warning-500/20">
<span className="flex-shrink-0 rounded-full border border-warning-500/20 bg-warning-500/15 px-2 py-0.5 text-xs text-warning-400">
{t('admin.paymentMethods.notConfigured', 'Не настроен')}
</span>
)}
{subOptionsInfo && (
<span className="flex-shrink-0 text-xs px-2 py-0.5 rounded-full bg-dark-700/50 text-dark-400">
<span className="flex-shrink-0 rounded-full bg-dark-700/50 px-2 py-0.5 text-xs text-dark-400">
{subOptionsInfo}
</span>
)}
@@ -193,9 +210,12 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
{/* Condition chips */}
{chips.length > 0 && (
<div className="flex items-center gap-1.5 mt-1.5 flex-wrap">
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{chips.map((chip, i) => (
<span key={i} className="text-xs px-2 py-0.5 rounded-md bg-accent-500/10 text-accent-400 border border-accent-500/15">
<span
key={i}
className="rounded-md border border-accent-500/15 bg-accent-500/10 px-2 py-0.5 text-xs text-accent-400"
>
{chip}
</span>
))}
@@ -206,54 +226,64 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
{/* Chevron */}
<button
onClick={onClick}
className="flex-shrink-0 p-1 text-dark-500 hover:text-dark-300 transition-colors"
className="flex-shrink-0 p-1 text-dark-500 transition-colors hover:text-dark-300"
>
<ChevronRightIcon />
</button>
</div>
)
);
}
// ============ Detail Modal ============
interface DetailModalProps {
config: PaymentMethodConfig
promoGroups: PromoGroupSimple[]
onClose: () => void
onSave: (methodId: string, data: Record<string, unknown>) => void
isSaving: boolean
config: PaymentMethodConfig;
promoGroups: PromoGroupSimple[];
onClose: () => void;
onSave: (methodId: string, data: Record<string, unknown>) => void;
isSaving: boolean;
}
function PaymentMethodDetailModal({ config, promoGroups, onClose, onSave, isSaving }: DetailModalProps) {
const { t } = useTranslation()
const displayName = config.display_name || config.default_display_name
const icon = METHOD_ICONS[config.method_id] || '\uD83D\uDCB3'
function PaymentMethodDetailModal({
config,
promoGroups,
onClose,
onSave,
isSaving,
}: DetailModalProps) {
const { t } = useTranslation();
const displayName = config.display_name || config.default_display_name;
const icon = METHOD_ICONS[config.method_id] || '\uD83D\uDCB3';
// Local state for editing
const [isEnabled, setIsEnabled] = useState(config.is_enabled)
const [customName, setCustomName] = useState(config.display_name || '')
const [subOptions, setSubOptions] = useState<Record<string, boolean>>(config.sub_options || {})
const [minAmount, setMinAmount] = useState(config.min_amount_kopeks?.toString() || '')
const [maxAmount, setMaxAmount] = useState(config.max_amount_kopeks?.toString() || '')
const [userTypeFilter, setUserTypeFilter] = useState(config.user_type_filter)
const [firstTopupFilter, setFirstTopupFilter] = useState(config.first_topup_filter)
const [promoGroupFilterMode, setPromoGroupFilterMode] = useState(config.promo_group_filter_mode)
const [selectedPromoGroupIds, setSelectedPromoGroupIds] = useState<number[]>(config.allowed_promo_group_ids)
const [isEnabled, setIsEnabled] = useState(config.is_enabled);
const [customName, setCustomName] = useState(config.display_name || '');
const [subOptions, setSubOptions] = useState<Record<string, boolean>>(config.sub_options || {});
const [minAmount, setMinAmount] = useState(config.min_amount_kopeks?.toString() || '');
const [maxAmount, setMaxAmount] = useState(config.max_amount_kopeks?.toString() || '');
const [userTypeFilter, setUserTypeFilter] = useState(config.user_type_filter);
const [firstTopupFilter, setFirstTopupFilter] = useState(config.first_topup_filter);
const [promoGroupFilterMode, setPromoGroupFilterMode] = useState(config.promo_group_filter_mode);
const [selectedPromoGroupIds, setSelectedPromoGroupIds] = useState<number[]>(
config.allowed_promo_group_ids,
);
// Escape to close
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [onClose])
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
// Scroll lock
useEffect(() => {
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = '' }
}, [])
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = '';
};
}, []);
const handleSave = () => {
const data: Record<string, unknown> = {
@@ -262,129 +292,148 @@ function PaymentMethodDetailModal({ config, promoGroups, onClose, onSave, isSavi
first_topup_filter: firstTopupFilter,
promo_group_filter_mode: promoGroupFilterMode,
allowed_promo_group_ids: promoGroupFilterMode === 'selected' ? selectedPromoGroupIds : [],
}
};
// Display name
if (customName.trim()) {
data.display_name = customName.trim()
data.display_name = customName.trim();
} else {
data.reset_display_name = true
data.reset_display_name = true;
}
// Sub-options
if (config.available_sub_options) {
data.sub_options = subOptions
data.sub_options = subOptions;
}
// Amounts
if (minAmount.trim()) {
data.min_amount_kopeks = parseInt(minAmount, 10) || null
data.min_amount_kopeks = parseInt(minAmount, 10) || null;
} else {
data.reset_min_amount = true
data.reset_min_amount = true;
}
if (maxAmount.trim()) {
data.max_amount_kopeks = parseInt(maxAmount, 10) || null
data.max_amount_kopeks = parseInt(maxAmount, 10) || null;
} else {
data.reset_max_amount = true
data.reset_max_amount = true;
}
onSave(config.method_id, data)
}
onSave(config.method_id, data);
};
const togglePromoGroup = (id: number) => {
setSelectedPromoGroupIds(prev =>
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
)
}
setSelectedPromoGroupIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
};
return (
<div className="fixed inset-0 z-50 flex items-start justify-center sm:items-center" onClick={onClose}>
<div
className="fixed inset-0 z-50 flex items-start justify-center sm:items-center"
onClick={onClose}
>
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" />
<div
className="relative w-full max-w-lg max-h-[90vh] overflow-y-auto m-4 rounded-2xl bg-dark-800 border border-dark-700 shadow-2xl"
onClick={e => e.stopPropagation()}
className="relative m-4 max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-2xl border border-dark-700 bg-dark-800 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="sticky top-0 z-10 flex items-center justify-between p-5 border-b border-dark-700 bg-dark-800 rounded-t-2xl">
<div className="sticky top-0 z-10 flex items-center justify-between rounded-t-2xl border-b border-dark-700 bg-dark-800 p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-dark-700/50 flex items-center justify-center text-xl">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-dark-700/50 text-xl">
{icon}
</div>
<div>
<h2 className="text-lg font-bold text-dark-50">{displayName}</h2>
<p className="text-xs text-dark-500">{METHOD_LABELS[config.method_id] || config.method_id}</p>
<p className="text-xs text-dark-500">
{METHOD_LABELS[config.method_id] || config.method_id}
</p>
</div>
</div>
<button onClick={onClose} className="p-2 rounded-lg hover:bg-dark-700 text-dark-400 hover:text-dark-200 transition-colors">
<button
onClick={onClose}
className="rounded-lg p-2 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
>
<CloseIcon />
</button>
</div>
<div className="p-5 space-y-6">
<div className="space-y-6 p-5">
{/* Enable toggle */}
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-dark-200">{t('admin.paymentMethods.methodEnabled', 'Метод включён')}</div>
<div className="text-sm font-medium text-dark-200">
{t('admin.paymentMethods.methodEnabled', 'Метод включён')}
</div>
{!config.is_provider_configured && (
<div className="text-xs text-warning-400 mt-0.5">{t('admin.paymentMethods.providerNotConfigured', 'Провайдер не настроен в env')}</div>
<div className="mt-0.5 text-xs text-warning-400">
{t('admin.paymentMethods.providerNotConfigured', 'Провайдер не настроен в env')}
</div>
)}
</div>
<button
onClick={() => setIsEnabled(!isEnabled)}
className={`relative w-12 h-7 rounded-full transition-colors ${
className={`relative h-7 w-12 rounded-full transition-colors ${
isEnabled ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span className={`absolute top-0.5 w-6 h-6 rounded-full bg-white shadow transition-transform ${
isEnabled ? 'left-[calc(100%-1.625rem)]' : 'left-0.5'
}`} />
<span
className={`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition-transform ${
isEnabled ? 'left-[calc(100%-1.625rem)]' : 'left-0.5'
}`}
/>
</button>
</div>
{/* Display name */}
<div>
<label className="block text-sm font-medium text-dark-200 mb-2">
<label className="mb-2 block text-sm font-medium text-dark-200">
{t('admin.paymentMethods.displayName', 'Отображаемое имя')}
</label>
<input
type="text"
value={customName}
onChange={e => setCustomName(e.target.value)}
onChange={(e) => setCustomName(e.target.value)}
placeholder={config.default_display_name}
className="w-full px-4 py-2.5 rounded-xl bg-dark-900/50 border border-dark-700 text-dark-100 placeholder:text-dark-500 focus:outline-none focus:border-accent-500/50 transition-colors"
className="w-full rounded-xl border border-dark-700 bg-dark-900/50 px-4 py-2.5 text-dark-100 transition-colors placeholder:text-dark-500 focus:border-accent-500/50 focus:outline-none"
/>
<p className="text-xs text-dark-500 mt-1">
{t('admin.paymentMethods.displayNameHint', 'Оставьте пустым для имени по умолчанию')}: {config.default_display_name}
<p className="mt-1 text-xs text-dark-500">
{t('admin.paymentMethods.displayNameHint', 'Оставьте пустым для имени по умолчанию')}:{' '}
{config.default_display_name}
</p>
</div>
{/* Sub-options */}
{config.available_sub_options && config.available_sub_options.length > 0 && (
<div>
<label className="block text-sm font-medium text-dark-200 mb-2">
<label className="mb-2 block text-sm font-medium text-dark-200">
{t('admin.paymentMethods.subOptions', 'Варианты оплаты')}
</label>
<div className="space-y-2">
{config.available_sub_options.map(opt => {
const enabled = subOptions[opt.id] !== false
{config.available_sub_options.map((opt) => {
const enabled = subOptions[opt.id] !== false;
return (
<button
key={opt.id}
onClick={() => setSubOptions(prev => ({ ...prev, [opt.id]: !enabled }))}
className={`w-full flex items-center justify-between p-3 rounded-xl border transition-all ${
onClick={() => setSubOptions((prev) => ({ ...prev, [opt.id]: !enabled }))}
className={`flex w-full items-center justify-between rounded-xl border p-3 transition-all ${
enabled
? 'bg-dark-700/30 border-accent-500/30 text-dark-100'
: 'bg-dark-900/30 border-dark-800 text-dark-500'
? 'border-accent-500/30 bg-dark-700/30 text-dark-100'
: 'border-dark-800 bg-dark-900/30 text-dark-500'
}`}
>
<span className="text-sm">{opt.name}</span>
<div className={`w-5 h-5 rounded flex items-center justify-center ${
enabled ? 'bg-accent-500 text-white' : 'bg-dark-700 border border-dark-600'
}`}>
<div
className={`flex h-5 w-5 items-center justify-center rounded ${
enabled
? 'bg-accent-500 text-white'
: 'border border-dark-600 bg-dark-700'
}`}
>
{enabled && <CheckIcon />}
</div>
</button>
)
);
})}
</div>
</div>
@@ -393,55 +442,58 @@ function PaymentMethodDetailModal({ config, promoGroups, onClose, onSave, isSavi
{/* Min/Max amounts */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-dark-200 mb-2">
<label className="mb-2 block text-sm font-medium text-dark-200">
{t('admin.paymentMethods.minAmount', 'Мин. сумма (коп.)')}
</label>
<input
type="number"
value={minAmount}
onChange={e => setMinAmount(e.target.value)}
onChange={(e) => setMinAmount(e.target.value)}
placeholder={config.default_min_amount_kopeks.toString()}
className="w-full px-4 py-2.5 rounded-xl bg-dark-900/50 border border-dark-700 text-dark-100 placeholder:text-dark-500 focus:outline-none focus:border-accent-500/50 transition-colors"
className="w-full rounded-xl border border-dark-700 bg-dark-900/50 px-4 py-2.5 text-dark-100 transition-colors placeholder:text-dark-500 focus:border-accent-500/50 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-dark-200 mb-2">
<label className="mb-2 block text-sm font-medium text-dark-200">
{t('admin.paymentMethods.maxAmount', 'Макс. сумма (коп.)')}
</label>
<input
type="number"
value={maxAmount}
onChange={e => setMaxAmount(e.target.value)}
onChange={(e) => setMaxAmount(e.target.value)}
placeholder={config.default_max_amount_kopeks.toString()}
className="w-full px-4 py-2.5 rounded-xl bg-dark-900/50 border border-dark-700 text-dark-100 placeholder:text-dark-500 focus:outline-none focus:border-accent-500/50 transition-colors"
className="w-full rounded-xl border border-dark-700 bg-dark-900/50 px-4 py-2.5 text-dark-100 transition-colors placeholder:text-dark-500 focus:border-accent-500/50 focus:outline-none"
/>
</div>
</div>
{/* Display conditions */}
<div className="pt-3 border-t border-dark-700">
<h3 className="text-sm font-semibold text-dark-200 mb-4">
<div className="border-t border-dark-700 pt-3">
<h3 className="mb-4 text-sm font-semibold text-dark-200">
{t('admin.paymentMethods.conditions', 'Условия отображения')}
</h3>
{/* User type filter */}
<div className="mb-4">
<label className="block text-sm text-dark-300 mb-2">
<label className="mb-2 block text-sm text-dark-300">
{t('admin.paymentMethods.userTypeFilter', 'Тип пользователя')}
</label>
<div className="flex gap-2">
{(['all', 'telegram', 'email'] as const).map(val => (
{(['all', 'telegram', 'email'] as const).map((val) => (
<button
key={val}
onClick={() => setUserTypeFilter(val)}
className={`flex-1 px-3 py-2 rounded-xl text-sm font-medium transition-all ${
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-all ${
userTypeFilter === val
? 'bg-accent-500/20 border border-accent-500/40 text-accent-300'
: 'bg-dark-900/50 border border-dark-700 text-dark-400 hover:border-dark-600'
? 'border border-accent-500/40 bg-accent-500/20 text-accent-300'
: 'border border-dark-700 bg-dark-900/50 text-dark-400 hover:border-dark-600'
}`}
>
{val === 'all' ? t('admin.paymentMethods.userTypeAll', 'Все') :
val === 'telegram' ? 'Telegram' : 'Email'}
{val === 'all'
? t('admin.paymentMethods.userTypeAll', 'Все')
: val === 'telegram'
? 'Telegram'
: 'Email'}
</button>
))}
</div>
@@ -449,23 +501,25 @@ function PaymentMethodDetailModal({ config, promoGroups, onClose, onSave, isSavi
{/* First topup filter */}
<div className="mb-4">
<label className="block text-sm text-dark-300 mb-2">
<label className="mb-2 block text-sm text-dark-300">
{t('admin.paymentMethods.firstTopupFilter', 'Первое пополнение')}
</label>
<div className="flex gap-2">
{(['any', 'yes', 'no'] as const).map(val => (
{(['any', 'yes', 'no'] as const).map((val) => (
<button
key={val}
onClick={() => setFirstTopupFilter(val)}
className={`flex-1 px-3 py-2 rounded-xl text-sm font-medium transition-all ${
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-all ${
firstTopupFilter === val
? 'bg-accent-500/20 border border-accent-500/40 text-accent-300'
: 'bg-dark-900/50 border border-dark-700 text-dark-400 hover:border-dark-600'
? 'border border-accent-500/40 bg-accent-500/20 text-accent-300'
: 'border border-dark-700 bg-dark-900/50 text-dark-400 hover:border-dark-600'
}`}
>
{val === 'any' ? t('admin.paymentMethods.firstTopupAny', 'Не важно') :
val === 'yes' ? t('admin.paymentMethods.firstTopupYes', 'Было') :
t('admin.paymentMethods.firstTopupNo', 'Не было')}
{val === 'any'
? t('admin.paymentMethods.firstTopupAny', 'Не важно')
: val === 'yes'
? t('admin.paymentMethods.firstTopupYes', 'Было')
: t('admin.paymentMethods.firstTopupNo', 'Не было')}
</button>
))}
</div>
@@ -473,55 +527,56 @@ function PaymentMethodDetailModal({ config, promoGroups, onClose, onSave, isSavi
{/* Promo groups filter */}
<div>
<label className="block text-sm text-dark-300 mb-2">
<label className="mb-2 block text-sm text-dark-300">
{t('admin.paymentMethods.promoGroupFilter', 'Промо-группы')}
</label>
<div className="flex gap-2 mb-3">
{(['all', 'selected'] as const).map(val => (
<div className="mb-3 flex gap-2">
{(['all', 'selected'] as const).map((val) => (
<button
key={val}
onClick={() => setPromoGroupFilterMode(val)}
className={`flex-1 px-3 py-2 rounded-xl text-sm font-medium transition-all ${
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-all ${
promoGroupFilterMode === val
? 'bg-accent-500/20 border border-accent-500/40 text-accent-300'
: 'bg-dark-900/50 border border-dark-700 text-dark-400 hover:border-dark-600'
? 'border border-accent-500/40 bg-accent-500/20 text-accent-300'
: 'border border-dark-700 bg-dark-900/50 text-dark-400 hover:border-dark-600'
}`}
>
{val === 'all'
? t('admin.paymentMethods.promoGroupAll', 'Все группы')
: t('admin.paymentMethods.promoGroupSelected', 'Выбранные')
}
: t('admin.paymentMethods.promoGroupSelected', 'Выбранные')}
</button>
))}
</div>
{promoGroupFilterMode === 'selected' && (
<div className="max-h-48 overflow-y-auto space-y-1.5 p-3 rounded-xl bg-dark-900/30 border border-dark-700/50">
<div className="max-h-48 space-y-1.5 overflow-y-auto rounded-xl border border-dark-700/50 bg-dark-900/30 p-3">
{promoGroups.length === 0 ? (
<p className="text-sm text-dark-500 text-center py-2">
<p className="py-2 text-center text-sm text-dark-500">
{t('admin.paymentMethods.noPromoGroups', 'Нет промо-групп')}
</p>
) : (
promoGroups.map(group => {
const selected = selectedPromoGroupIds.includes(group.id)
promoGroups.map((group) => {
const selected = selectedPromoGroupIds.includes(group.id);
return (
<button
key={group.id}
onClick={() => togglePromoGroup(group.id)}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-all ${
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm transition-all ${
selected
? 'bg-accent-500/15 text-accent-300'
: 'text-dark-400 hover:bg-dark-800/50'
}`}
>
<span>{group.name}</span>
<div className={`w-4 h-4 rounded flex items-center justify-center ${
selected ? 'bg-accent-500 text-white' : 'border border-dark-600'
}`}>
<div
className={`flex h-4 w-4 items-center justify-center rounded ${
selected ? 'bg-accent-500 text-white' : 'border border-dark-600'
}`}
>
{selected && <CheckIcon />}
</div>
</button>
)
);
})
)}
</div>
@@ -531,20 +586,20 @@ function PaymentMethodDetailModal({ config, promoGroups, onClose, onSave, isSavi
</div>
{/* Footer */}
<div className="sticky bottom-0 flex items-center gap-3 p-5 border-t border-dark-700 bg-dark-800 rounded-b-2xl">
<div className="sticky bottom-0 flex items-center gap-3 rounded-b-2xl border-t border-dark-700 bg-dark-800 p-5">
<button
onClick={onClose}
className="flex-1 px-4 py-2.5 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors font-medium"
className="flex-1 rounded-xl bg-dark-700 px-4 py-2.5 font-medium text-dark-300 transition-colors hover:bg-dark-600"
>
{t('common.cancel', 'Отмена')}
</button>
<button
onClick={handleSave}
disabled={isSaving}
className="flex-1 px-4 py-2.5 rounded-xl bg-accent-500 text-white hover:bg-accent-400 disabled:opacity-50 transition-colors font-medium flex items-center justify-center gap-2"
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-accent-500 px-4 py-2.5 font-medium text-white transition-colors hover:bg-accent-400 disabled:opacity-50"
>
{isSaving ? (
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<SaveIcon />
)}
@@ -553,126 +608,130 @@ function PaymentMethodDetailModal({ config, promoGroups, onClose, onSave, isSavi
</div>
</div>
</div>
)
);
}
// ============ Toast ============
function Toast({ message, onClose }: { message: string; onClose: () => void }) {
useEffect(() => {
const timer = setTimeout(onClose, 3000)
return () => clearTimeout(timer)
}, [onClose])
const timer = setTimeout(onClose, 3000);
return () => clearTimeout(timer);
}, [onClose]);
return (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 px-5 py-3 rounded-xl bg-success-500/90 text-white text-sm font-medium shadow-lg backdrop-blur-sm animate-fade-in flex items-center gap-2">
<div className="fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 animate-fade-in items-center gap-2 rounded-xl bg-success-500/90 px-5 py-3 text-sm font-medium text-white shadow-lg backdrop-blur-sm">
<CheckIcon />
{message}
</div>
)
);
}
// ============ Main Page ============
export default function AdminPaymentMethods() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { t } = useTranslation();
const queryClient = useQueryClient();
const [methods, setMethods] = useState<PaymentMethodConfig[]>([])
const [selectedMethod, setSelectedMethod] = useState<PaymentMethodConfig | null>(null)
const [orderChanged, setOrderChanged] = useState(false)
const [toastMessage, setToastMessage] = useState<string | null>(null)
const [methods, setMethods] = useState<PaymentMethodConfig[]>([]);
const [selectedMethod, setSelectedMethod] = useState<PaymentMethodConfig | null>(null);
const [orderChanged, setOrderChanged] = useState(false);
const [toastMessage, setToastMessage] = useState<string | null>(null);
// Fetch payment methods
const { data: fetchedMethods, isLoading } = useQuery({
queryKey: ['admin-payment-methods'],
queryFn: adminPaymentMethodsApi.getAll,
})
});
// Fetch promo groups
const { data: promoGroups = [] } = useQuery({
queryKey: ['admin-payment-methods-promo-groups'],
queryFn: adminPaymentMethodsApi.getPromoGroups,
})
});
// Sync fetched data to local state
useEffect(() => {
if (fetchedMethods && !orderChanged) {
setMethods(fetchedMethods)
setMethods(fetchedMethods);
}
}, [fetchedMethods, orderChanged])
}, [fetchedMethods, orderChanged]);
// Save order mutation
const saveOrderMutation = useMutation({
mutationFn: (methodIds: string[]) => adminPaymentMethodsApi.updateOrder(methodIds),
onSuccess: () => {
setOrderChanged(false)
queryClient.invalidateQueries({ queryKey: ['admin-payment-methods'] })
setToastMessage(t('admin.paymentMethods.orderSaved', 'Порядок сохранён'))
setOrderChanged(false);
queryClient.invalidateQueries({ queryKey: ['admin-payment-methods'] });
setToastMessage(t('admin.paymentMethods.orderSaved', 'Порядок сохранён'));
},
onError: () => {
setToastMessage(t('common.error', 'Ошибка'))
setToastMessage(t('common.error', 'Ошибка'));
},
})
});
// Update method mutation
const updateMethodMutation = useMutation({
mutationFn: ({ methodId, data }: { methodId: string; data: Record<string, unknown> }) =>
adminPaymentMethodsApi.update(methodId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-payment-methods'] })
setSelectedMethod(null)
setToastMessage(t('admin.paymentMethods.saved', 'Настройки сохранены'))
queryClient.invalidateQueries({ queryKey: ['admin-payment-methods'] });
setSelectedMethod(null);
setToastMessage(t('admin.paymentMethods.saved', 'Настройки сохранены'));
},
onError: () => {
setToastMessage(t('common.error', 'Ошибка'))
setToastMessage(t('common.error', 'Ошибка'));
},
})
});
// DnD sensors
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event
const { active, over } = event;
if (over && active.id !== over.id) {
setMethods(prev => {
const oldIndex = prev.findIndex(m => m.method_id === active.id)
const newIndex = prev.findIndex(m => m.method_id === over.id)
if (oldIndex === -1 || newIndex === -1) return prev
return arrayMove(prev, oldIndex, newIndex)
})
setOrderChanged(true)
setMethods((prev) => {
const oldIndex = prev.findIndex((m) => m.method_id === active.id);
const newIndex = prev.findIndex((m) => m.method_id === over.id);
if (oldIndex === -1 || newIndex === -1) return prev;
return arrayMove(prev, oldIndex, newIndex);
});
setOrderChanged(true);
}
}, [])
}, []);
const handleSaveOrder = () => {
saveOrderMutation.mutate(methods.map(m => m.method_id))
}
saveOrderMutation.mutate(methods.map((m) => m.method_id));
};
const handleSaveMethod = (methodId: string, data: Record<string, unknown>) => {
updateMethodMutation.mutate({ methodId, data })
}
updateMethodMutation.mutate({ methodId, data });
};
const handleCloseToast = useCallback(() => setToastMessage(null), [])
const handleCloseToast = useCallback(() => setToastMessage(null), []);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3">
<Link
to="/admin"
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 hover:border-dark-600 transition-colors"
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
>
<BackIcon />
</Link>
<div>
<h1 className="text-2xl font-bold text-dark-50">{t('admin.paymentMethods.title', 'Платёжные методы')}</h1>
<p className="text-sm text-dark-400">{t('admin.paymentMethods.description', 'Настройка порядка и условий отображения')}</p>
<h1 className="text-2xl font-bold text-dark-50">
{t('admin.paymentMethods.title', 'Платёжные методы')}
</h1>
<p className="text-sm text-dark-400">
{t('admin.paymentMethods.description', 'Настройка порядка и условий отображения')}
</p>
</div>
</div>
{orderChanged && (
@@ -682,7 +741,7 @@ export default function AdminPaymentMethods() {
className="btn-primary flex items-center gap-2"
>
{saveOrderMutation.isPending ? (
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<SaveIcon />
)}
@@ -692,22 +751,32 @@ export default function AdminPaymentMethods() {
</div>
{/* Drag hint */}
<div className="text-sm text-dark-500 flex items-center gap-2">
<div className="flex items-center gap-2 text-sm text-dark-500">
<GripIcon />
{t('admin.paymentMethods.dragHint', 'Перетаскивайте карточки для изменения порядка. Нажмите для настройки.')}
{t(
'admin.paymentMethods.dragHint',
'Перетаскивайте карточки для изменения порядка. Нажмите для настройки.',
)}
</div>
{/* Methods list */}
<div className="card">
{isLoading ? (
<div className="flex items-center justify-center py-16">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : methods.length > 0 ? (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={methods.map(m => m.method_id)} strategy={verticalListSortingStrategy}>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={methods.map((m) => m.method_id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{methods.map(config => (
{methods.map((config) => (
<SortablePaymentCard
key={config.method_id}
config={config}
@@ -718,11 +787,13 @@ export default function AdminPaymentMethods() {
</SortableContext>
</DndContext>
) : (
<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">
<div className="py-12 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">
<span className="text-3xl">{'\uD83D\uDCB3'}</span>
</div>
<div className="text-dark-400">{t('admin.paymentMethods.noMethods', 'Нет настроенных платёжных методов')}</div>
<div className="text-dark-400">
{t('admin.paymentMethods.noMethods', 'Нет настроенных платёжных методов')}
</div>
</div>
)}
</div>
@@ -739,9 +810,7 @@ export default function AdminPaymentMethods() {
)}
{/* Toast */}
{toastMessage && (
<Toast message={toastMessage} onClose={handleCloseToast} />
)}
{toastMessage && <Toast message={toastMessage} onClose={handleCloseToast} />}
</div>
)
);
}

View File

@@ -1,37 +1,42 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import { adminPaymentsApi } from '../api/adminPayments'
import { useCurrency } from '../hooks/useCurrency'
import type { PendingPayment, PaginatedResponse } from '../types'
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { adminPaymentsApi } from '../api/adminPayments';
import { useCurrency } from '../hooks/useCurrency';
import type { PendingPayment, PaginatedResponse } from '../types';
export default function AdminPayments() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { formatAmount, currencySymbol } = useCurrency()
const { t } = useTranslation();
const queryClient = useQueryClient();
const { formatAmount, currencySymbol } = useCurrency();
const [page, setPage] = useState(1)
const [methodFilter, setMethodFilter] = useState<string>('')
const [checkingPaymentId, setCheckingPaymentId] = useState<string | null>(null)
const [page, setPage] = useState(1);
const [methodFilter, setMethodFilter] = useState<string>('');
const [checkingPaymentId, setCheckingPaymentId] = useState<string | null>(null);
// Fetch payments
const { data: payments, isLoading, refetch } = useQuery<PaginatedResponse<PendingPayment>>({
const {
data: payments,
isLoading,
refetch,
} = useQuery<PaginatedResponse<PendingPayment>>({
queryKey: ['admin-payments', page, methodFilter],
queryFn: () => adminPaymentsApi.getPendingPayments({
page,
per_page: 20,
method_filter: methodFilter || undefined,
}),
queryFn: () =>
adminPaymentsApi.getPendingPayments({
page,
per_page: 20,
method_filter: methodFilter || undefined,
}),
refetchInterval: 30000, // Auto-refresh every 30 seconds
})
});
// Fetch stats
const { data: stats } = useQuery({
queryKey: ['admin-payments-stats'],
queryFn: adminPaymentsApi.getStats,
refetchInterval: 30000,
})
});
// Check payment mutation
const checkPaymentMutation = useMutation({
@@ -39,49 +44,66 @@ export default function AdminPayments() {
adminPaymentsApi.checkPaymentStatus(method, paymentId),
onSuccess: async (result) => {
if (result.status_changed) {
await refetch()
queryClient.invalidateQueries({ queryKey: ['admin-payments-stats'] })
await refetch();
queryClient.invalidateQueries({ queryKey: ['admin-payments-stats'] });
} else {
await refetch()
await refetch();
}
},
onSettled: () => {
setCheckingPaymentId(null)
setCheckingPaymentId(null);
},
})
});
const handleCheckPayment = (payment: PendingPayment) => {
setCheckingPaymentId(`${payment.method}_${payment.id}`)
checkPaymentMutation.mutate({ method: payment.method, paymentId: payment.id })
}
setCheckingPaymentId(`${payment.method}_${payment.id}`);
checkPaymentMutation.mutate({ method: payment.method, paymentId: payment.id });
};
// Get unique methods from stats for filter
const methodOptions = stats?.by_method ? Object.keys(stats.by_method) : []
const methodOptions = stats?.by_method ? Object.keys(stats.by_method) : [];
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3">
<Link
to="/admin"
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 hover:border-dark-600 transition-colors"
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
>
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</Link>
<div>
<h1 className="text-2xl font-bold text-dark-50">{t('admin.payments.title', 'Проверка платежей')}</h1>
<p className="text-sm text-dark-400">{t('admin.payments.description', 'Ожидающие платежи за последние 24 часа')}</p>
<h1 className="text-2xl font-bold text-dark-50">
{t('admin.payments.title', 'Проверка платежей')}
</h1>
<p className="text-sm text-dark-400">
{t('admin.payments.description', 'Ожидающие платежи за последние 24 часа')}
</p>
</div>
</div>
<button
onClick={() => refetch()}
className="btn-secondary flex items-center gap-2"
>
<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" />
<button onClick={() => refetch()} className="btn-secondary flex items-center gap-2">
<svg
className="h-4 w-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>
{t('common.refresh', 'Обновить')}
</button>
@@ -89,18 +111,20 @@ export default function AdminPayments() {
{/* Stats cards */}
{stats && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
<div className="p-4 rounded-xl bg-dark-800/50 border border-dark-700/50">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
<div className="rounded-xl border border-dark-700/50 bg-dark-800/50 p-4">
<div className="text-2xl font-bold text-dark-50">{stats.total_pending}</div>
<div className="text-sm text-dark-400">{t('admin.payments.totalPending', 'Всего ожидает')}</div>
<div className="text-sm text-dark-400">
{t('admin.payments.totalPending', 'Всего ожидает')}
</div>
</div>
{Object.entries(stats.by_method).map(([method, count]) => (
<div
key={method}
className={`p-4 rounded-xl border cursor-pointer transition-all ${
className={`cursor-pointer rounded-xl border p-4 transition-all ${
methodFilter === method
? 'bg-accent-500/20 border-accent-500/50'
: 'bg-dark-800/50 border-dark-700/50 hover:border-dark-600'
? 'border-accent-500/50 bg-accent-500/20'
: 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
}`}
onClick={() => setMethodFilter(methodFilter === method ? '' : method)}
>
@@ -113,11 +137,13 @@ export default function AdminPayments() {
{/* Filter */}
{methodOptions.length > 0 && (
<div className="flex items-center gap-3 flex-wrap">
<span className="text-sm text-dark-400">{t('admin.payments.filterByMethod', 'Фильтр по методу')}:</span>
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm text-dark-400">
{t('admin.payments.filterByMethod', 'Фильтр по методу')}:
</span>
<button
onClick={() => setMethodFilter('')}
className={`px-3 py-1.5 rounded-lg text-sm transition-all ${
className={`rounded-lg px-3 py-1.5 text-sm transition-all ${
methodFilter === ''
? 'bg-accent-500 text-white'
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
@@ -129,7 +155,7 @@ export default function AdminPayments() {
<button
key={method}
onClick={() => setMethodFilter(method)}
className={`px-3 py-1.5 rounded-lg text-sm transition-all ${
className={`rounded-lg px-3 py-1.5 text-sm transition-all ${
methodFilter === method
? 'bg-accent-500 text-white'
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
@@ -145,28 +171,30 @@ export default function AdminPayments() {
<div className="card">
{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 className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : payments?.items && payments.items.length > 0 ? (
<div className="space-y-3">
{payments.items.map((payment) => {
const paymentKey = `${payment.method}_${payment.id}`
const isChecking = checkingPaymentId === paymentKey
const paymentKey = `${payment.method}_${payment.id}`;
const isChecking = checkingPaymentId === paymentKey;
return (
<div
key={paymentKey}
className="p-4 rounded-xl bg-dark-800/30 border border-dark-700/30"
className="rounded-xl border border-dark-700/30 bg-dark-800/30 p-4"
>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="font-semibold text-dark-100">{payment.method_display}</span>
<span className="text-sm px-2 py-0.5 rounded-full bg-dark-700/50 text-dark-300">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="mb-2 flex flex-wrap items-center gap-2">
<span className="font-semibold text-dark-100">
{payment.method_display}
</span>
<span className="rounded-full bg-dark-700/50 px-2 py-0.5 text-sm text-dark-300">
{payment.status_emoji} {payment.status_text}
</span>
{payment.is_paid && (
<span className="text-sm px-2 py-0.5 rounded-full bg-success-500/20 text-success-400">
<span className="rounded-full bg-success-500/20 px-2 py-0.5 text-sm text-success-400">
{t('admin.payments.paid', 'Оплачено')}
</span>
)}
@@ -174,16 +202,18 @@ export default function AdminPayments() {
<div className="text-lg font-semibold text-dark-50">
{formatAmount(payment.amount_rubles)} {currencySymbol}
</div>
<div className="text-sm text-dark-400 mt-1">
<div className="mt-1 text-sm text-dark-400">
ID: <code className="text-dark-300">{payment.identifier}</code>
</div>
<div className="text-xs text-dark-500 mt-1">
<div className="mt-1 text-xs text-dark-500">
{new Date(payment.created_at).toLocaleString()}
</div>
{/* User info */}
{(payment.user_username || payment.user_telegram_id) && (
<div className="text-sm text-dark-400 mt-2">
<span className="text-dark-500">{t('admin.payments.user', 'Пользователь')}:</span>{' '}
<div className="mt-2 text-sm text-dark-400">
<span className="text-dark-500">
{t('admin.payments.user', 'Пользователь')}:
</span>{' '}
{payment.user_username ? (
<span className="text-dark-200">@{payment.user_username}</span>
) : (
@@ -198,7 +228,7 @@ export default function AdminPayments() {
href={payment.payment_url}
target="_blank"
rel="noopener noreferrer"
className="btn-secondary text-xs px-3 py-1.5"
className="btn-secondary px-3 py-1.5 text-xs"
>
{t('admin.payments.openLink', 'Открыть ссылку')}
</a>
@@ -207,13 +237,24 @@ export default function AdminPayments() {
<button
onClick={() => handleCheckPayment(payment)}
disabled={isChecking}
className="btn-primary text-xs px-3 py-1.5"
className="btn-primary px-3 py-1.5 text-xs"
>
{isChecking ? (
<span className="flex items-center gap-1">
<svg className="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
<svg className="h-3 w-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
{t('admin.payments.checking', 'Проверка...')}
</span>
@@ -225,52 +266,70 @@ export default function AdminPayments() {
</div>
</div>
{/* Show result after check */}
{checkPaymentMutation.isSuccess && checkPaymentMutation.variables?.paymentId === payment.id && (
<div className={`mt-3 p-2 rounded-lg text-sm ${
checkPaymentMutation.data?.status_changed
? 'bg-success-500/10 border border-success-500/30 text-success-400'
: 'bg-dark-700/30 text-dark-400'
}`}>
{checkPaymentMutation.data?.message}
</div>
)}
{checkPaymentMutation.isSuccess &&
checkPaymentMutation.variables?.paymentId === payment.id && (
<div
className={`mt-3 rounded-lg p-2 text-sm ${
checkPaymentMutation.data?.status_changed
? 'border border-success-500/30 bg-success-500/10 text-success-400'
: 'bg-dark-700/30 text-dark-400'
}`}
>
{checkPaymentMutation.data?.message}
</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="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<div className="py-12 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">
<svg
className="h-8 w-8 text-dark-500"
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.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div className="text-dark-400">{t('admin.payments.noPayments', 'Нет ожидающих платежей')}</div>
<div className="text-dark-400">
{t('admin.payments.noPayments', 'Нет ожидающих платежей')}
</div>
</div>
)}
{/* Pagination */}
{payments && payments.pages > 1 && (
<div className="mt-4 flex items-center gap-3 flex-wrap text-sm text-dark-500">
<div className="mt-4 flex flex-wrap items-center gap-3 text-sm text-dark-500">
<button
type="button"
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
disabled={payments.page <= 1}
className={`btn-secondary text-xs sm:text-sm flex-1 sm:flex-none min-w-[100px] ${
payments.page <= 1 ? 'opacity-50 cursor-not-allowed' : ''
className={`btn-secondary min-w-[100px] flex-1 text-xs sm:flex-none sm:text-sm ${
payments.page <= 1 ? 'cursor-not-allowed opacity-50' : ''
}`}
>
{t('common.back', 'Назад')}
</button>
<div className="flex-1 text-center">
{t('balance.page', '{current} / {total}', { current: payments.page, total: payments.pages })}
{t('balance.page', '{current} / {total}', {
current: payments.page,
total: payments.pages,
})}
</div>
<button
type="button"
onClick={() => setPage((prev) => Math.min(payments.pages, prev + 1))}
disabled={payments.page >= payments.pages}
className={`btn-secondary text-xs sm:text-sm flex-1 sm:flex-none min-w-[100px] ${
payments.page >= payments.pages ? 'opacity-50 cursor-not-allowed' : ''
className={`btn-secondary min-w-[100px] flex-1 text-xs sm:flex-none sm:text-sm ${
payments.page >= payments.pages ? 'cursor-not-allowed opacity-50' : ''
}`}
>
{t('common.next', 'Далее')}
@@ -279,5 +338,5 @@ export default function AdminPayments() {
)}
</div>
</div>
)
);
}

View File

@@ -1,77 +1,104 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import {
promoOffersApi,
PromoOfferTemplate,
PromoOfferTemplateUpdateRequest,
PromoOfferBroadcastRequest,
PromoOfferLog,
TARGET_SEGMENTS,
TargetSegment,
OFFER_TYPE_CONFIG,
OfferType,
} from '../api/promoOffers'
} from '../api/promoOffers';
// Icons
const BackIcon = () => (
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</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 className="h-4 w-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 XIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg className="h-4 w-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 SendIcon = () => (
<svg className="w-5 h-5" 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 className="h-5 w-5" 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 ClockIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
)
);
const UserIcon = () => (
<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 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
/>
</svg>
)
);
const CheckIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg className="h-4 w-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 UsersIcon = () => (
<svg className="w-5 h-5" 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 className="h-5 w-5" 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>
)
);
// Helper functions
const formatDateTime = (date: string | null): string => {
if (!date) return '-'
if (!date) return '-';
return new Date(date).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
});
};
const getActionLabel = (action: string): string => {
const labels: Record<string, string> = {
@@ -79,9 +106,9 @@ const getActionLabel = (action: string): string => {
claimed: 'Активировано',
consumed: 'Использовано',
disabled: 'Деактивировано',
}
return labels[action] || action
}
};
return labels[action] || action;
};
const getActionColor = (action: string): string => {
const colors: Record<string, string> = {
@@ -89,37 +116,39 @@ const getActionColor = (action: string): string => {
claimed: 'bg-emerald-500/20 text-emerald-400',
consumed: 'bg-purple-500/20 text-purple-400',
disabled: 'bg-dark-600 text-dark-400',
}
return colors[action] || 'bg-dark-600 text-dark-400'
}
};
return colors[action] || 'bg-dark-600 text-dark-400';
};
const getOfferTypeIcon = (offerType: string): string => {
return OFFER_TYPE_CONFIG[offerType as OfferType]?.icon || '🎁'
}
return OFFER_TYPE_CONFIG[offerType as OfferType]?.icon || '🎁';
};
const getOfferTypeLabel = (offerType: string): string => {
return OFFER_TYPE_CONFIG[offerType as OfferType]?.label || offerType
}
return OFFER_TYPE_CONFIG[offerType as OfferType]?.label || offerType;
};
// Template Edit Modal
interface TemplateEditModalProps {
template: PromoOfferTemplate
onSave: (data: PromoOfferTemplateUpdateRequest) => void
onClose: () => void
isLoading?: boolean
template: PromoOfferTemplate;
onSave: (data: PromoOfferTemplateUpdateRequest) => void;
onClose: () => void;
isLoading?: boolean;
}
function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEditModalProps) {
const [name, setName] = useState(template.name)
const [messageText, setMessageText] = useState(template.message_text)
const [buttonText, setButtonText] = useState(template.button_text)
const [validHours, setValidHours] = useState(template.valid_hours)
const [discountPercent, setDiscountPercent] = useState(template.discount_percent)
const [activeDiscountHours, setActiveDiscountHours] = useState(template.active_discount_hours || 0)
const [testDurationHours, setTestDurationHours] = useState(template.test_duration_hours || 0)
const [isActive, setIsActive] = useState(template.is_active)
const [name, setName] = useState(template.name);
const [messageText, setMessageText] = useState(template.message_text);
const [buttonText, setButtonText] = useState(template.button_text);
const [validHours, setValidHours] = useState(template.valid_hours);
const [discountPercent, setDiscountPercent] = useState(template.discount_percent);
const [activeDiscountHours, setActiveDiscountHours] = useState(
template.active_discount_hours || 0,
);
const [testDurationHours, setTestDurationHours] = useState(template.test_duration_hours || 0);
const [isActive, setIsActive] = useState(template.is_active);
const isTestAccess = template.offer_type === 'test_access'
const isTestAccess = template.offer_type === 'test_access';
const handleSubmit = () => {
const data: PromoOfferTemplateUpdateRequest = {
@@ -129,84 +158,84 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi
valid_hours: validHours,
discount_percent: discountPercent,
is_active: isActive,
}
};
if (isTestAccess) {
data.test_duration_hours = testDurationHours > 0 ? testDurationHours : undefined
data.test_duration_hours = testDurationHours > 0 ? testDurationHours : undefined;
} else {
data.active_discount_hours = activeDiscountHours > 0 ? activeDiscountHours : undefined
data.active_discount_hours = activeDiscountHours > 0 ? activeDiscountHours : undefined;
}
onSave(data)
}
onSave(data);
};
return (
<div className="fixed inset-0 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">
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-xl bg-dark-800">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-700">
<div className="flex items-center justify-between border-b border-dark-700 p-4">
<div className="flex items-center gap-3">
<span className="text-2xl">{getOfferTypeIcon(template.offer_type)}</span>
<h2 className="text-lg font-semibold text-dark-100">
Редактирование шаблона
</h2>
<h2 className="text-lg font-semibold text-dark-100">Редактирование шаблона</h2>
</div>
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded-lg transition-colors">
<button onClick={onClose} className="rounded-lg p-1 transition-colors hover:bg-dark-700">
<XIcon />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
<div className="flex-1 space-y-4 overflow-y-auto p-4">
<div>
<label className="block text-sm text-dark-300 mb-1">Название шаблона</label>
<label className="mb-1 block text-sm text-dark-300">Название шаблона</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"
onChange={(e) => setName(e.target.value)}
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-dark-300 mb-1">Текст сообщения</label>
<label className="mb-1 block text-sm text-dark-300">Текст сообщения</label>
<textarea
value={messageText}
onChange={e => setMessageText(e.target.value)}
onChange={(e) => setMessageText(e.target.value)}
rows={4}
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"
className="w-full resize-none rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-dark-300 mb-1">Текст кнопки</label>
<label className="mb-1 block text-sm text-dark-300">Текст кнопки</label>
<input
type="text"
value={buttonText}
onChange={e => setButtonText(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"
onChange={(e) => setButtonText(e.target.value)}
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-dark-300 mb-1">Срок предложения (часы)</label>
<label className="mb-1 block text-sm text-dark-300">Срок предложения (часы)</label>
<input
type="number"
value={validHours}
onChange={e => setValidHours(Math.max(1, parseInt(e.target.value) || 1))}
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"
onChange={(e) => setValidHours(Math.max(1, parseInt(e.target.value) || 1))}
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
min={1}
/>
<p className="text-xs text-dark-500 mt-1">Время на активацию</p>
<p className="mt-1 text-xs text-dark-500">Время на активацию</p>
</div>
{!isTestAccess && (
<div>
<label className="block text-sm text-dark-300 mb-1">Скидка (%)</label>
<label className="mb-1 block text-sm text-dark-300">Скидка (%)</label>
<input
type="number"
value={discountPercent}
onChange={e => setDiscountPercent(Math.min(100, Math.max(0, parseInt(e.target.value) || 0)))}
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"
onChange={(e) =>
setDiscountPercent(Math.min(100, Math.max(0, parseInt(e.target.value) || 0)))
}
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
min={0}
max={100}
/>
@@ -216,40 +245,42 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi
{isTestAccess ? (
<div>
<label className="block text-sm text-dark-300 mb-1">Длительность доступа (часы)</label>
<label className="mb-1 block text-sm text-dark-300">
Длительность доступа (часы)
</label>
<input
type="number"
value={testDurationHours}
onChange={e => setTestDurationHours(Math.max(0, parseInt(e.target.value) || 0))}
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"
onChange={(e) => setTestDurationHours(Math.max(0, parseInt(e.target.value) || 0))}
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
min={0}
/>
<p className="text-xs text-dark-500 mt-1">0 = по умолчанию</p>
<p className="mt-1 text-xs text-dark-500">0 = по умолчанию</p>
</div>
) : (
<div>
<label className="block text-sm text-dark-300 mb-1">Действие скидки (часы)</label>
<label className="mb-1 block text-sm text-dark-300">Действие скидки (часы)</label>
<input
type="number"
value={activeDiscountHours}
onChange={e => setActiveDiscountHours(Math.max(0, parseInt(e.target.value) || 0))}
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"
onChange={(e) => setActiveDiscountHours(Math.max(0, parseInt(e.target.value) || 0))}
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
min={0}
/>
<p className="text-xs text-dark-500 mt-1">Сколько действует скидка после активации</p>
<p className="mt-1 text-xs text-dark-500">Сколько действует скидка после активации</p>
</div>
)}
<label className="flex items-center gap-3 cursor-pointer">
<label className="flex cursor-pointer items-center gap-3">
<button
type="button"
onClick={() => setIsActive(!isActive)}
className={`w-10 h-6 rounded-full transition-colors relative ${
className={`relative h-6 w-10 rounded-full transition-colors ${
isActive ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
isActive ? 'left-5' : 'left-1'
}`}
/>
@@ -259,89 +290,89 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi
</div>
{/* Footer */}
<div className="flex justify-end gap-3 p-4 border-t border-dark-700">
<div className="flex justify-end gap-3 border-t border-dark-700 p-4">
<button
onClick={onClose}
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
>
Отмена
</button>
<button
onClick={handleSubmit}
disabled={!name.trim() || 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"
className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoading ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</div>
</div>
)
);
}
// Send Offer Modal
interface SendOfferModalProps {
templates: PromoOfferTemplate[]
onSend: (templateId: number, target: string | null, userId: number | null) => void
onClose: () => void
isLoading?: boolean
templates: PromoOfferTemplate[];
onSend: (templateId: number, target: string | null, userId: number | null) => void;
onClose: () => void;
isLoading?: boolean;
}
function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModalProps) {
const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(templates[0]?.id || null)
const [sendMode, setSendMode] = useState<'segment' | 'user'>('segment')
const [selectedTarget, setSelectedTarget] = useState<TargetSegment>('active')
const [userId, setUserId] = useState('')
const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(
templates[0]?.id || null,
);
const [sendMode, setSendMode] = useState<'segment' | 'user'>('segment');
const [selectedTarget, setSelectedTarget] = useState<TargetSegment>('active');
const [userId, setUserId] = useState('');
const selectedTemplate = templates.find(t => t.id === selectedTemplateId)
const activeTemplates = templates.filter(t => t.is_active)
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId);
const activeTemplates = templates.filter((t) => t.is_active);
const handleSubmit = () => {
if (!selectedTemplateId) return
if (!selectedTemplateId) return;
if (sendMode === 'user') {
const id = parseInt(userId)
if (!id) return
onSend(selectedTemplateId, null, id)
const id = parseInt(userId);
if (!id) return;
onSend(selectedTemplateId, null, id);
} else {
onSend(selectedTemplateId, selectedTarget, null)
onSend(selectedTemplateId, selectedTarget, null);
}
}
};
const isValid = () => {
if (!selectedTemplateId) return false
if (sendMode === 'user' && !userId.trim()) return false
return true
}
if (!selectedTemplateId) return false;
if (sendMode === 'user' && !userId.trim()) return false;
return true;
};
return (
<div className="fixed inset-0 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">
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-xl bg-dark-800">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-700">
<div className="flex items-center justify-between border-b border-dark-700 p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-accent-500/20 rounded-lg">
<div className="rounded-lg bg-accent-500/20 p-2">
<SendIcon />
</div>
<h2 className="text-lg font-semibold text-dark-100">
Отправка промопредложения
</h2>
<h2 className="text-lg font-semibold text-dark-100">Отправка промопредложения</h2>
</div>
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded-lg transition-colors">
<button onClick={onClose} className="rounded-lg p-1 transition-colors hover:bg-dark-700">
<XIcon />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{/* Template Selection */}
<div>
<label className="block text-sm text-dark-300 mb-2">Шаблон предложения</label>
<label className="mb-2 block text-sm text-dark-300">Шаблон предложения</label>
<div className="space-y-2">
{activeTemplates.map(template => (
{activeTemplates.map((template) => (
<button
key={template.id}
onClick={() => setSelectedTemplateId(template.id)}
className={`w-full p-3 rounded-lg border text-left transition-colors ${
className={`w-full rounded-lg border p-3 text-left transition-colors ${
selectedTemplateId === template.id
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-600 bg-dark-700 hover:border-dark-500'
@@ -358,9 +389,7 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
{template.valid_hours}ч на активацию
</div>
</div>
{selectedTemplateId === template.id && (
<CheckIcon />
)}
{selectedTemplateId === template.id && <CheckIcon />}
</div>
</button>
))}
@@ -369,11 +398,11 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
{/* Send Mode */}
<div>
<label className="block text-sm text-dark-300 mb-2">Кому отправить</label>
<div className="flex gap-2 mb-3">
<label className="mb-2 block text-sm text-dark-300">Кому отправить</label>
<div className="mb-3 flex gap-2">
<button
onClick={() => setSendMode('segment')}
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
className={`flex-1 rounded-lg border py-2 text-sm font-medium transition-colors ${
sendMode === 'segment'
? 'border-accent-500 bg-accent-500/10 text-accent-400'
: 'border-dark-600 text-dark-400 hover:text-dark-200'
@@ -384,7 +413,7 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
</button>
<button
onClick={() => setSendMode('user')}
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
className={`flex-1 rounded-lg border py-2 text-sm font-medium transition-colors ${
sendMode === 'user'
? 'border-accent-500 bg-accent-500/10 text-accent-400'
: 'border-dark-600 text-dark-400 hover:text-dark-200'
@@ -398,33 +427,35 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
{sendMode === 'segment' ? (
<select
value={selectedTarget}
onChange={e => setSelectedTarget(e.target.value as TargetSegment)}
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"
onChange={(e) => setSelectedTarget(e.target.value as TargetSegment)}
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
>
{Object.entries(TARGET_SEGMENTS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
<option key={key} value={key}>
{label}
</option>
))}
</select>
) : (
<input
type="text"
value={userId}
onChange={e => setUserId(e.target.value)}
onChange={(e) => setUserId(e.target.value)}
placeholder="Telegram ID или User ID"
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"
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
/>
)}
</div>
{/* Preview */}
{selectedTemplate && (
<div className="p-4 bg-dark-700/50 rounded-lg">
<h4 className="text-sm font-medium text-dark-300 mb-2">Предпросмотр</h4>
<div className="text-sm text-dark-200 whitespace-pre-wrap">
<div className="rounded-lg bg-dark-700/50 p-4">
<h4 className="mb-2 text-sm font-medium text-dark-300">Предпросмотр</h4>
<div className="whitespace-pre-wrap text-sm text-dark-200">
{selectedTemplate.message_text}
</div>
<div className="mt-3">
<span className="inline-block px-3 py-1.5 bg-accent-500 text-white text-sm rounded-lg">
<span className="inline-block rounded-lg bg-accent-500 px-3 py-1.5 text-sm text-white">
{selectedTemplate.button_text}
</span>
</div>
@@ -433,17 +464,17 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
</div>
{/* Footer */}
<div className="flex justify-end gap-3 p-4 border-t border-dark-700">
<div className="flex justify-end gap-3 border-t border-dark-700 p-4">
<button
onClick={onClose}
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
>
Отмена
</button>
<button
onClick={handleSubmit}
disabled={!isValid() || 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 flex items-center gap-2"
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
>
<SendIcon />
{isLoading ? 'Отправка...' : 'Отправить'}
@@ -451,88 +482,106 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
</div>
</div>
</div>
)
);
}
// Result Modal
interface ResultModalProps {
title: string
message: string
isSuccess: boolean
onClose: () => void
title: string;
message: string;
isSuccess: boolean;
onClose: () => void;
}
function ResultModal({ title, message, isSuccess, onClose }: ResultModalProps) {
return (
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
<div className="bg-dark-800 rounded-xl p-6 max-w-sm w-full text-center">
<div className={`w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center ${
isSuccess ? 'bg-emerald-500/20' : 'bg-error-500/20'
}`}>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="w-full max-w-sm rounded-xl bg-dark-800 p-6 text-center">
<div
className={`mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full ${
isSuccess ? 'bg-emerald-500/20' : 'bg-error-500/20'
}`}
>
{isSuccess ? (
<svg className="w-8 h-8 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg
className="h-8 w-8 text-emerald-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>
) : (
<svg className="w-8 h-8 text-error-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg
className="h-8 w-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>
<h3 className="text-lg font-semibold text-dark-100 mb-2">{title}</h3>
<p className="text-dark-400 mb-6">{message}</p>
<h3 className="mb-2 text-lg font-semibold text-dark-100">{title}</h3>
<p className="mb-6 text-dark-400">{message}</p>
<button
onClick={onClose}
className="px-6 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors"
className="rounded-lg bg-accent-500 px-6 py-2 text-white transition-colors hover:bg-accent-600"
>
Закрыть
</button>
</div>
</div>
)
);
}
export default function AdminPromoOffers() {
const queryClient = useQueryClient()
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<'templates' | 'send' | 'logs'>('templates')
const [editingTemplate, setEditingTemplate] = useState<PromoOfferTemplate | null>(null)
const [showSendModal, setShowSendModal] = useState(false)
const [resultModal, setResultModal] = useState<{ title: string; message: string; isSuccess: boolean } | null>(null)
const [activeTab, setActiveTab] = useState<'templates' | 'send' | 'logs'>('templates');
const [editingTemplate, setEditingTemplate] = useState<PromoOfferTemplate | null>(null);
const [showSendModal, setShowSendModal] = useState(false);
const [resultModal, setResultModal] = useState<{
title: string;
message: string;
isSuccess: boolean;
} | null>(null);
// Queries
const { data: templatesData, isLoading: templatesLoading } = useQuery({
queryKey: ['admin-promo-templates'],
queryFn: promoOffersApi.getTemplates,
})
});
const { data: logsData, isLoading: logsLoading } = useQuery({
queryKey: ['admin-promo-logs'],
queryFn: () => promoOffersApi.getLogs({ limit: 100 }),
enabled: activeTab === 'logs',
})
});
// Mutations
const updateTemplateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: PromoOfferTemplateUpdateRequest }) =>
promoOffersApi.updateTemplate(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-promo-templates'] })
setEditingTemplate(null)
queryClient.invalidateQueries({ queryKey: ['admin-promo-templates'] });
setEditingTemplate(null);
},
})
});
const broadcastMutation = useMutation({
mutationFn: promoOffersApi.broadcastOffer,
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['admin-promo-logs'] })
setShowSendModal(false)
queryClient.invalidateQueries({ queryKey: ['admin-promo-logs'] });
setShowSendModal(false);
let message = `Создано предложений: ${result.created_offers}`
let message = `Создано предложений: ${result.created_offers}`;
if (result.notifications_sent > 0 || result.notifications_failed > 0) {
message += `\nУведомлений отправлено: ${result.notifications_sent}`
message += `\nУведомлений отправлено: ${result.notifications_sent}`;
if (result.notifications_failed > 0) {
message += ` (не доставлено: ${result.notifications_failed})`
message += ` (не доставлено: ${result.notifications_failed})`;
}
}
@@ -540,22 +589,23 @@ export default function AdminPromoOffers() {
title: 'Отправлено!',
message,
isSuccess: true,
})
});
},
onError: (error: any) => {
onError: (error: unknown) => {
const axiosErr = error as { response?: { data?: { detail?: string } } };
setResultModal({
title: 'Ошибка',
message: error.response?.data?.detail || 'Не удалось отправить предложение',
message: axiosErr.response?.data?.detail || 'Не удалось отправить предложение',
isSuccess: false,
})
});
},
})
});
const handleSendOffer = (templateId: number, target: string | null, userId: number | null) => {
const template = templatesData?.items.find(t => t.id === templateId)
if (!template) return
const template = templatesData?.items.find((t) => t.id === templateId);
if (!template) return;
const data: any = {
const data: PromoOfferBroadcastRequest = {
notification_type: template.offer_type,
valid_hours: template.valid_hours,
discount_percent: template.discount_percent,
@@ -570,29 +620,24 @@ export default function AdminPromoOffers() {
send_notification: true,
message_text: template.message_text,
button_text: template.button_text,
}
...(target ? { target } : {}),
...(userId ? { telegram_id: userId } : {}),
};
if (target) {
data.target = target
}
if (userId) {
data.telegram_id = userId
}
broadcastMutation.mutate(data);
};
broadcastMutation.mutate(data)
}
const templates = templatesData?.items || []
const logs = logsData?.items || []
const templates = templatesData?.items || [];
const logs = logsData?.items || [];
return (
<div className="animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link
to="/admin"
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 hover:border-dark-600 transition-colors"
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
>
<BackIcon />
</Link>
@@ -603,7 +648,7 @@ export default function AdminPromoOffers() {
</div>
<button
onClick={() => setShowSendModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors"
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
>
<SendIcon />
Отправить
@@ -611,10 +656,10 @@ export default function AdminPromoOffers() {
</div>
{/* Tabs */}
<div className="flex gap-1 mb-6 p-1 bg-dark-800 rounded-lg w-fit">
<div className="mb-6 flex w-fit gap-1 rounded-lg bg-dark-800 p-1">
<button
onClick={() => setActiveTab('templates')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
className={`rounded-md px-4 py-2 text-sm font-medium transition-colors ${
activeTab === 'templates'
? 'bg-dark-700 text-dark-100'
: 'text-dark-400 hover:text-dark-200'
@@ -624,10 +669,8 @@ export default function AdminPromoOffers() {
</button>
<button
onClick={() => setActiveTab('logs')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === 'logs'
? 'bg-dark-700 text-dark-100'
: 'text-dark-400 hover:text-dark-200'
className={`rounded-md px-4 py-2 text-sm font-medium transition-colors ${
activeTab === 'logs' ? 'bg-dark-700 text-dark-100' : 'text-dark-400 hover:text-dark-200'
}`}
>
Логи
@@ -639,10 +682,10 @@ export default function AdminPromoOffers() {
<>
{templatesLoading ? (
<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 className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : templates.length === 0 ? (
<div className="text-center py-12">
<div className="py-12 text-center">
<p className="text-dark-400">Нет шаблонов</p>
</div>
) : (
@@ -650,21 +693,23 @@ export default function AdminPromoOffers() {
{templates.map((template) => (
<div
key={template.id}
className={`p-4 bg-dark-800 rounded-xl border transition-colors ${
className={`rounded-xl border bg-dark-800 p-4 transition-colors ${
template.is_active ? 'border-dark-700' : 'border-dark-700/50 opacity-60'
}`}
>
<div className="flex items-start justify-between mb-3">
<div className="mb-3 flex items-start justify-between">
<div className="flex items-center gap-2">
<span className="text-2xl">{getOfferTypeIcon(template.offer_type)}</span>
<div>
<h3 className="font-medium text-dark-100">{template.name}</h3>
<span className="text-xs text-dark-500">{getOfferTypeLabel(template.offer_type)}</span>
<span className="text-xs text-dark-500">
{getOfferTypeLabel(template.offer_type)}
</span>
</div>
</div>
<button
onClick={() => setEditingTemplate(template)}
className="p-2 bg-dark-700 text-dark-300 rounded-lg hover:bg-dark-600 hover:text-dark-100 transition-colors"
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
>
<EditIcon />
</button>
@@ -674,7 +719,9 @@ export default function AdminPromoOffers() {
{template.discount_percent > 0 && (
<div className="flex justify-between">
<span className="text-dark-400">Скидка:</span>
<span className="text-accent-400 font-medium">{template.discount_percent}%</span>
<span className="font-medium text-accent-400">
{template.discount_percent}%
</span>
</div>
)}
<div className="flex justify-between">
@@ -695,14 +742,14 @@ export default function AdminPromoOffers() {
)}
</div>
<div className="mt-3 pt-3 border-t border-dark-700">
<div className="mt-3 border-t border-dark-700 pt-3">
<div className="flex items-center gap-2">
{template.is_active ? (
<span className="px-2 py-0.5 text-xs bg-emerald-500/20 text-emerald-400 rounded">
<span className="rounded bg-emerald-500/20 px-2 py-0.5 text-xs text-emerald-400">
Активен
</span>
) : (
<span className="px-2 py-0.5 text-xs bg-dark-600 text-dark-400 rounded">
<span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
Неактивен
</span>
)}
@@ -720,37 +767,34 @@ export default function AdminPromoOffers() {
<>
{logsLoading ? (
<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 className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : logs.length === 0 ? (
<div className="text-center py-12">
<div className="py-12 text-center">
<p className="text-dark-400">Нет записей</p>
</div>
) : (
<div className="space-y-3">
{logs.map((log: PromoOfferLog) => (
<div
key={log.id}
className="p-4 bg-dark-800 rounded-xl border border-dark-700"
>
<div key={log.id} className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-dark-700 rounded-full flex items-center justify-center">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-dark-700">
<UserIcon />
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<div className="mb-1 flex items-center gap-2">
<span className="font-medium text-dark-100">
{log.user?.full_name || log.user?.username || `User #${log.user_id}`}
</span>
<span className={`px-2 py-0.5 text-xs rounded ${getActionColor(log.action)}`}>
<span
className={`rounded px-2 py-0.5 text-xs ${getActionColor(log.action)}`}
>
{getActionLabel(log.action)}
</span>
</div>
<div className="text-sm text-dark-400">
{log.source && (
<span>{getOfferTypeLabel(log.source)}</span>
)}
{log.source && <span>{getOfferTypeLabel(log.source)}</span>}
{log.percent && log.percent > 0 && (
<span className="ml-2 text-accent-400">{log.percent}%</span>
)}
@@ -799,5 +843,5 @@ export default function AdminPromoOffers() {
/>
)}
</div>
)
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,80 +1,113 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import {
serversApi,
ServerListItem,
ServerDetail,
ServerUpdateRequest
} from '../api/servers'
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { serversApi, ServerListItem, ServerDetail, ServerUpdateRequest } from '../api/servers';
// Icons
const BackIcon = () => (
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</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 className="h-5 w-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 className="h-4 w-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}>
<svg className="h-4 w-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}>
<svg className="h-4 w-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 className="h-4 w-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 ''
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
}
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
server: ServerDetail;
onSave: (data: ServerUpdateRequest) => void;
onClose: () => void;
isLoading?: boolean;
}
function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) {
const { t } = useTranslation()
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 [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 = {
@@ -84,55 +117,59 @@ function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) {
price_kopeks: priceKopeks,
max_users: maxUsers || undefined,
sort_order: sortOrder,
}
onSave(data)
}
};
onSave(data);
};
return (
<div className="fixed inset-0 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">
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-xl bg-dark-800">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-700">
<div className="flex items-center justify-between border-b border-dark-700 p-4">
<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>
<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">
<button onClick={onClose} className="rounded-lg p-1 transition-colors hover:bg-dark-700">
<XIcon />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
<div className="flex-1 space-y-4 overflow-y-auto p-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">
<label className="mb-1 block text-sm text-dark-300">
{t('admin.servers.originalName')}
</label>
<div className="rounded-lg border border-dark-600 bg-dark-700/50 px-3 py-2 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>
<label className="mb-1 block text-sm text-dark-300">
{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"
onChange={(e) => setDisplayName(e.target.value)}
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
placeholder={t('admin.servers.displayNamePlaceholder')}
/>
</div>
{/* Description */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.description')}</label>
<label className="mb-1 block text-sm text-dark-300">
{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"
onChange={(e) => setDescription(e.target.value)}
className="w-full resize-none rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
rows={2}
placeholder={t('admin.servers.descriptionPlaceholder')}
/>
@@ -140,46 +177,50 @@ function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) {
{/* Country Code */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.countryCode')}</label>
<label className="mb-1 block text-sm text-dark-300">
{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"
onChange={(e) => setCountryCode(e.target.value.toUpperCase().slice(0, 2))}
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
placeholder="RU"
maxLength={2}
/>
{countryCode && (
<span className="ml-2 text-xl">{getCountryFlag(countryCode)}</span>
)}
{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>
<label className="mb-1 block text-sm text-dark-300">{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"
onChange={(e) => setPriceKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)}
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
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>
<p className="mt-1 text-xs text-dark-500">{t('admin.servers.priceHint')}</p>
</div>
{/* Max Users */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.maxUsers')}</label>
<label className="mb-1 block text-sm text-dark-300">
{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"
onChange={(e) =>
setMaxUsers(e.target.value ? Math.max(0, parseInt(e.target.value)) : null)
}
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
min={0}
placeholder={t('admin.servers.unlimited')}
/>
@@ -191,34 +232,39 @@ function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) {
{/* Sort Order */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.sortOrder')}</label>
<label className="mb-1 block text-sm text-dark-300">
{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"
onChange={(e) => setSortOrder(parseInt(e.target.value) || 0)}
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
/>
</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="border-t border-dark-700 pt-4">
<h4 className="mb-2 text-sm font-medium text-dark-300">{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">
<div className="rounded-lg bg-dark-700/50 p-2">
<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">
<div className="rounded-lg bg-dark-700/50 p-2">
<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">
<div className="mt-2 rounded-lg bg-dark-700/50 p-2">
<span className="text-sm text-dark-400">{t('admin.servers.usedByTariffs')}:</span>
<div className="mt-1 flex flex-wrap gap-1">
{server.tariffs_using.map((tariff) => (
<span
key={tariff}
className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-300"
>
{tariff}
</span>
))}
@@ -229,103 +275,103 @@ function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) {
</div>
{/* Footer */}
<div className="flex justify-end gap-3 p-4 border-t border-dark-700">
<div className="flex justify-end gap-3 border-t border-dark-700 p-4">
<button
onClick={onClose}
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
>
{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"
className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoading ? t('common.loading') : t('common.save')}
</button>
</div>
</div>
</div>
)
);
}
export default function AdminServers() {
const { t } = useTranslation()
const queryClient = useQueryClient()
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)
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)
queryClient.invalidateQueries({ queryKey: ['admin-servers'] });
setEditingServer(null);
},
})
});
const toggleMutation = useMutation({
mutationFn: serversApi.toggleServer,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-servers'] })
queryClient.invalidateQueries({ queryKey: ['admin-servers'] });
},
})
});
const toggleTrialMutation = useMutation({
mutationFn: serversApi.toggleTrial,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-servers'] })
queryClient.invalidateQueries({ queryKey: ['admin-servers'] });
},
})
});
const syncMutation = useMutation({
mutationFn: serversApi.syncServers,
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['admin-servers'] })
alert(result.message)
queryClient.invalidateQueries({ queryKey: ['admin-servers'] });
alert(result.message);
},
})
});
const handleEdit = async (serverId: number) => {
setLoadingServerId(serverId)
setLoadError(null)
setLoadingServerId(serverId);
setLoadError(null);
try {
const detail = await serversApi.getServer(serverId)
setEditingServer(detail)
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)
console.error('Failed to load server:', error);
const errorMessage = error instanceof Error ? error.message : t('admin.servers.loadError');
setLoadError(errorMessage);
} finally {
setLoadingServerId(null)
setLoadingServerId(null);
}
}
};
const handleSave = (data: ServerUpdateRequest) => {
if (editingServer) {
updateMutation.mutate({ id: editingServer.id, data })
updateMutation.mutate({ id: editingServer.id, data });
}
}
};
const servers = serversData?.servers || []
const servers = serversData?.servers || [];
return (
<div className="animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link
to="/admin"
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 hover:border-dark-600 transition-colors"
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
>
<BackIcon />
</Link>
@@ -337,7 +383,7 @@ export default function AdminServers() {
<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"
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
>
<SyncIcon />
{syncMutation.isPending ? t('admin.servers.syncing') : t('admin.servers.sync')}
@@ -347,10 +393,10 @@ export default function AdminServers() {
{/* 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 className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : servers.length === 0 ? (
<div className="text-center py-12">
<div className="py-12 text-center">
<p className="text-dark-400">{t('admin.servers.noServers')}</p>
<button
onClick={() => syncMutation.mutate()}
@@ -364,27 +410,27 @@ export default function AdminServers() {
{servers.map((server: ServerListItem) => (
<div
key={server.id}
className={`p-4 bg-dark-800 rounded-xl border transition-colors ${
className={`rounded-xl border bg-dark-800 p-4 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">
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<span className="text-lg">{getCountryFlag(server.country_code)}</span>
<h3 className="font-medium text-dark-100 truncate">{server.display_name}</h3>
<h3 className="truncate font-medium text-dark-100">{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">
<span className="rounded bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
{t('admin.servers.trial')}
</span>
)}
{!server.is_available && (
<span className="px-2 py-0.5 text-xs bg-dark-600 text-dark-400 rounded">
<span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
{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">
<span className="rounded bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
{t('admin.servers.full')}
</span>
)}
@@ -396,7 +442,9 @@ export default function AdminServers() {
{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>
<span className="max-w-[200px] truncate font-mono text-xs text-dark-500">
{server.squad_uuid}
</span>
</div>
</div>
@@ -404,12 +452,14 @@ export default function AdminServers() {
{/* Toggle Available */}
<button
onClick={() => toggleMutation.mutate(server.id)}
className={`p-2 rounded-lg transition-colors ${
className={`rounded-lg p-2 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')}
title={
server.is_available ? t('admin.servers.disable') : t('admin.servers.enable')
}
>
{server.is_available ? <CheckIcon /> : <XIcon />}
</button>
@@ -417,7 +467,7 @@ export default function AdminServers() {
{/* Toggle Trial */}
<button
onClick={() => toggleTrialMutation.mutate(server.id)}
className={`p-2 rounded-lg transition-colors ${
className={`rounded-lg p-2 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'
@@ -431,11 +481,11 @@ export default function AdminServers() {
<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"
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100 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" />
<div className="h-4 w-4 animate-spin rounded-full border-2 border-dark-300 border-t-transparent" />
) : (
<EditIcon />
)}
@@ -449,12 +499,9 @@ export default function AdminServers() {
{/* 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">
<div className="fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-lg bg-error-500/90 px-4 py-3 text-white shadow-lg">
<span>{loadError}</span>
<button
onClick={() => setLoadError(null)}
className="p-1 hover:bg-white/20 rounded"
>
<button onClick={() => setLoadError(null)} className="rounded p-1 hover:bg-white/20">
<XIcon />
</button>
</div>
@@ -470,5 +517,5 @@ export default function AdminServers() {
/>
)}
</div>
)
);
}

View File

@@ -1,121 +1,120 @@
import { useState, useMemo, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { adminSettingsApi, SettingDefinition } from '../api/adminSettings'
import { themeColorsApi } from '../api/themeColors'
import { useFavoriteSettings } from '../hooks/useFavoriteSettings'
import { useState, useMemo, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { adminSettingsApi, SettingDefinition } from '../api/adminSettings';
import { themeColorsApi } from '../api/themeColors';
import { useFavoriteSettings } from '../hooks/useFavoriteSettings';
import { MenuIcon, MENU_SECTIONS, MenuItem, formatSettingKey } from '../components/admin';
import { AnalyticsTab } from '../components/admin/AnalyticsTab';
import { BrandingTab } from '../components/admin/BrandingTab';
import { ThemeTab } from '../components/admin/ThemeTab';
import { FavoritesTab } from '../components/admin/FavoritesTab';
import { SettingsTab } from '../components/admin/SettingsTab';
import { SettingsSidebar } from '../components/admin/SettingsSidebar';
import {
MenuIcon,
MENU_SECTIONS,
MenuItem,
formatSettingKey
} from '../components/admin'
import { AnalyticsTab } from '../components/admin/AnalyticsTab'
import { BrandingTab } from '../components/admin/BrandingTab'
import { ThemeTab } from '../components/admin/ThemeTab'
import { FavoritesTab } from '../components/admin/FavoritesTab'
import { SettingsTab } from '../components/admin/SettingsTab'
import { SettingsSidebar } from '../components/admin/SettingsSidebar'
import { SettingsSearch, SettingsSearchMobile, SettingsSearchResults } from '../components/admin/SettingsSearch'
SettingsSearch,
SettingsSearchMobile,
SettingsSearchResults,
} from '../components/admin/SettingsSearch';
export default function AdminSettings() {
const { t } = useTranslation()
const { t } = useTranslation();
// State
const [activeSection, setActiveSection] = useState('branding')
const [searchQuery, setSearchQuery] = useState('')
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [activeSection, setActiveSection] = useState('branding');
const [searchQuery, setSearchQuery] = useState('');
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
// Favorites hook
const { favorites, toggleFavorite, isFavorite } = useFavoriteSettings()
const { favorites, toggleFavorite, isFavorite } = useFavoriteSettings();
// Scroll to top on mount and section change (fix for mobile webviews)
useEffect(() => {
// Use requestAnimationFrame for smoother scroll on mobile
requestAnimationFrame(() => {
window.scrollTo({ top: 0, behavior: 'instant' })
document.documentElement.scrollTop = 0
document.body.scrollTop = 0
})
}, [activeSection])
window.scrollTo({ top: 0, behavior: 'instant' });
document.documentElement.scrollTop = 0;
document.body.scrollTop = 0;
});
}, [activeSection]);
// Queries
const { data: themeColors } = useQuery({
queryKey: ['theme-colors'],
queryFn: themeColorsApi.getColors,
})
});
const { data: allSettings } = useQuery({
queryKey: ['admin-settings'],
queryFn: () => adminSettingsApi.getSettings(),
})
});
// Get current menu item configuration
const currentMenuItem = useMemo(() => {
for (const section of MENU_SECTIONS) {
const item = section.items.find((i: MenuItem) => i.id === activeSection)
if (item) return item
const item = section.items.find((i: MenuItem) => i.id === activeSection);
if (item) return item;
}
return null
}, [activeSection])
return null;
}, [activeSection]);
// Get categories for current section
const currentCategories = useMemo(() => {
if (!currentMenuItem?.categories || !allSettings || !Array.isArray(allSettings)) return []
if (!currentMenuItem?.categories || !allSettings || !Array.isArray(allSettings)) return [];
const categoryMap = new Map<string, SettingDefinition[]>()
const categoryMap = new Map<string, SettingDefinition[]>();
for (const setting of allSettings) {
if (currentMenuItem.categories.includes(setting.category.key)) {
if (!categoryMap.has(setting.category.key)) {
categoryMap.set(setting.category.key, [])
categoryMap.set(setting.category.key, []);
}
categoryMap.get(setting.category.key)!.push(setting)
categoryMap.get(setting.category.key)!.push(setting);
}
}
return Array.from(categoryMap.entries()).map(([key, settings]) => ({
key,
label: t(`admin.settings.categories.${key}`, key),
settings
}))
}, [currentMenuItem, allSettings, t])
settings,
}));
}, [currentMenuItem, allSettings, t]);
// Filter settings for search - GLOBAL search across all settings
const filteredSettings = useMemo(() => {
if (!allSettings || !Array.isArray(allSettings) || !searchQuery) return []
if (!allSettings || !Array.isArray(allSettings) || !searchQuery) return [];
const q = searchQuery.toLowerCase().trim()
if (!q) return []
const q = searchQuery.toLowerCase().trim();
if (!q) return [];
return allSettings.filter((s: SettingDefinition) => {
// Search by key
if (s.key.toLowerCase().includes(q)) return true
if (s.key.toLowerCase().includes(q)) return true;
// Search by original name
if (s.name?.toLowerCase().includes(q)) return true
if (s.name?.toLowerCase().includes(q)) return true;
// Search by translated name
const formattedKey = formatSettingKey(s.name || s.key)
const translatedName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey)
if (translatedName.toLowerCase().includes(q)) return true
const formattedKey = formatSettingKey(s.name || s.key);
const translatedName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
if (translatedName.toLowerCase().includes(q)) return true;
// Search by description
if (s.hint?.description?.toLowerCase().includes(q)) return true
if (s.hint?.description?.toLowerCase().includes(q)) return true;
// Search by category
const categoryLabel = t(`admin.settings.categories.${s.category.key}`, s.category.key)
if (categoryLabel.toLowerCase().includes(q)) return true
const categoryLabel = t(`admin.settings.categories.${s.category.key}`, s.category.key);
if (categoryLabel.toLowerCase().includes(q)) return true;
return false
})
}, [allSettings, searchQuery, t])
return false;
});
}, [allSettings, searchQuery, t]);
// Favorite settings
const favoriteSettings = useMemo(() => {
if (!allSettings || !Array.isArray(allSettings)) return []
return allSettings.filter((s: SettingDefinition) => favorites.includes(s.key))
}, [allSettings, favorites])
if (!allSettings || !Array.isArray(allSettings)) return [];
return allSettings.filter((s: SettingDefinition) => favorites.includes(s.key));
}, [allSettings, favorites]);
// Render content based on active section
const renderContent = () => {
@@ -129,16 +128,16 @@ export default function AdminSettings() {
isFavorite={isFavorite}
toggleFavorite={toggleFavorite}
/>
)
);
}
switch (activeSection) {
case 'analytics':
return <AnalyticsTab />
return <AnalyticsTab />;
case 'branding':
return <BrandingTab accentColor={themeColors?.accent} />
return <BrandingTab accentColor={themeColors?.accent} />;
case 'theme':
return <ThemeTab />
return <ThemeTab />;
case 'favorites':
return (
<FavoritesTab
@@ -146,9 +145,19 @@ export default function AdminSettings() {
isFavorite={isFavorite}
toggleFavorite={toggleFavorite}
/>
)
);
default:
if (['payments', 'subscriptions', 'interface', 'notifications', 'database', 'system', 'users'].includes(activeSection)) {
if (
[
'payments',
'subscriptions',
'interface',
'notifications',
'database',
'system',
'users',
].includes(activeSection)
) {
return (
<SettingsTab
categories={currentCategories}
@@ -157,18 +166,18 @@ export default function AdminSettings() {
isFavorite={isFavorite}
toggleFavorite={toggleFavorite}
/>
)
);
}
return null
return null;
}
}
};
return (
<div className="flex min-h-screen pt-safe">
<div className="pt-safe flex min-h-screen">
{/* Mobile overlay */}
{mobileMenuOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setMobileMenuOpen(false)}
/>
)}
@@ -183,18 +192,18 @@ export default function AdminSettings() {
/>
{/* Main content */}
<main className="flex-1 min-w-0">
<main className="min-w-0 flex-1">
{/* Header */}
<div className="sticky top-0 z-30 bg-dark-900/95 backdrop-blur-xl border-b border-dark-700/50 p-3 sm:p-4">
<div className="sticky top-0 z-30 border-b border-dark-700/50 bg-dark-900/95 p-3 backdrop-blur-xl sm:p-4">
<div className="flex items-center gap-3">
<button
onClick={() => setMobileMenuOpen(true)}
className="p-2 rounded-xl bg-dark-800 hover:bg-dark-700 transition-colors lg:hidden"
className="rounded-xl bg-dark-800 p-2 transition-colors hover:bg-dark-700 lg:hidden"
>
<MenuIcon />
</button>
<h2 className="text-lg sm:text-xl font-semibold text-dark-100 truncate">
<h2 className="truncate text-lg font-semibold text-dark-100 sm:text-xl">
{t(`admin.settings.${activeSection}`)}
</h2>
@@ -207,22 +216,14 @@ export default function AdminSettings() {
/>
</div>
<SettingsSearchMobile
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
<SettingsSearchMobile searchQuery={searchQuery} setSearchQuery={setSearchQuery} />
<SettingsSearchResults
searchQuery={searchQuery}
resultsCount={filteredSettings.length}
/>
<SettingsSearchResults searchQuery={searchQuery} resultsCount={filteredSettings.length} />
</div>
{/* Content */}
<div className="p-3 sm:p-4 lg:p-6">
{renderContent()}
</div>
<div className="p-3 sm:p-4 lg:p-6">{renderContent()}</div>
</main>
</div>
)
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,44 +1,56 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import { adminApi, AdminTicket, AdminTicketDetail, AdminTicketMessage } from '../api/admin'
import { ticketsApi } from '../api/tickets'
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { adminApi, AdminTicket, AdminTicketDetail, AdminTicketMessage } from '../api/admin';
import { ticketsApi } from '../api/tickets';
const BackIcon = () => (
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
)
);
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)
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
return null;
}
const mediaUrl = ticketsApi.getMediaUrl(message.media_file_id)
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 className="flex h-40 w-full animate-pulse items-center justify-center rounded-lg bg-dark-800">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
)}
{imageError ? (
<div className="w-full h-32 bg-dark-800 rounded-lg flex items-center justify-center text-dark-400 text-sm">
<div className="flex h-32 w-full items-center justify-center rounded-lg bg-dark-800 text-sm text-dark-400">
{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 ${
className={`max-h-64 max-w-full cursor-pointer rounded-lg transition-opacity hover:opacity-90 ${
imageLoaded ? '' : 'hidden'
}`}
onLoad={() => setImageLoaded(true)}
@@ -47,26 +59,36 @@ function AdminMessageMedia({ message, t }: { message: AdminTicketMessage; t: (ke
/>
)}
{message.media_caption && (
<p className="text-xs text-dark-400 mt-1">{message.media_caption}</p>
<p className="mt-1 text-xs text-dark-400">{message.media_caption}</p>
)}
{showFullImage && (
<div
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4"
onClick={() => setShowFullImage(false)}
>
<button
className="absolute top-4 right-4 text-white/70 hover:text-white"
className="absolute right-4 top-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}>
<svg
className="h-6 w-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" />
<img
src={mediaUrl}
alt={message.media_caption || 'Attached image'}
className="max-h-full max-w-full object-contain"
/>
</div>
)}
</div>
)
);
}
return (
@@ -75,129 +97,160 @@ function AdminMessageMedia({ message, t }: { message: AdminTicketMessage; t: (ke
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"
className="inline-flex items-center gap-2 rounded-lg bg-dark-700 px-3 py-2 text-sm text-dark-200 transition-colors hover:bg-dark-600"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg
className="h-4 w-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 [showSettings, setShowSettings] = useState(false)
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 [showSettings, setShowSettings] = useState(false);
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,
}),
})
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'] })
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'] })
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 })
}
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'
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'
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 } = ticket.user
if (first_name || last_name) return `${first_name || ''} ${last_name || ''}`.trim()
if (username) return `@${username}`
return 'User'
}
if (!ticket.user) return 'Unknown';
const { first_name, last_name, username } = ticket.user;
if (first_name || last_name) return `${first_name || ''} ${last_name || ''}`.trim();
if (username) return `@${username}`;
return 'User';
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text).catch(() => {
const textarea = document.createElement('textarea')
textarea.value = text
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
})
}
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
});
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link
to="/admin"
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 hover:border-dark-600 transition-colors"
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
>
<BackIcon />
</Link>
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('admin.tickets.title')}</h1>
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">
{t('admin.tickets.title')}
</h1>
</div>
<button
onClick={() => setShowSettings(true)}
className="btn-secondary flex items-center gap-2"
>
<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" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<svg
className="h-5 w-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"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
{t('admin.tickets.settings')}
</button>
@@ -205,7 +258,7 @@ export default function AdminTickets() {
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<div className="card text-center">
<div className="stat-value">{stats.total}</div>
<div className="stat-label">{t('admin.tickets.total')}</div>
@@ -222,22 +275,25 @@ export default function AdminTickets() {
<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="card col-span-2 text-center 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">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Ticket List */}
<div className="lg:col-span-1 card">
<div className="flex justify-between items-center mb-4">
<div className="card lg:col-span-1">
<div className="mb-4 flex items-center justify-between">
<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"
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(1);
}}
className="input w-auto px-3 py-1.5 text-sm"
>
<option value="">{t('admin.tickets.allStatuses')}</option>
<option value="open">{t('admin.tickets.statusOpen')}</option>
@@ -249,47 +305,54 @@ export default function AdminTickets() {
{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 className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : ticketsData?.items.length === 0 ? (
<div className="text-center py-12 text-dark-500">{t('admin.tickets.noTickets')}</div>
<div className="py-12 text-center text-dark-500">{t('admin.tickets.noTickets')}</div>
) : (
<div className="space-y-2 max-h-[500px] overflow-y-auto scrollbar-hide">
<div className="scrollbar-hide max-h-[500px] space-y-2 overflow-y-auto">
{ticketsData?.items.map((ticket) => (
<button
key={ticket.id}
onClick={() => setSelectedTicketId(ticket.id)}
className={`w-full text-left p-4 rounded-xl border transition-all ${
className={`w-full rounded-xl border p-4 text-left transition-all ${
selectedTicketId === ticket.id
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
: 'border-dark-700/50 bg-dark-800/30 hover:border-dark-600'
}`}
>
<div className="flex justify-between items-start gap-2 mb-2">
<span className="text-dark-100 font-medium truncate">
<div className="mb-2 flex items-start justify-between gap-2">
<span className="truncate font-medium text-dark-100">
#{ticket.id} {ticket.title}
</span>
<span className={getStatusBadge(ticket.status)}>
{t(`admin.tickets.status${ticket.status.charAt(0).toUpperCase() + ticket.status.slice(1)}`)}
{t(
`admin.tickets.status${ticket.status.charAt(0).toUpperCase() + ticket.status.slice(1)}`,
)}
</span>
</div>
<div className="text-xs text-dark-500">
{formatUser(ticket)}
{ticket.user?.telegram_id && (
<button
onClick={(e) => { e.stopPropagation(); copyToClipboard(String(ticket.user!.telegram_id)) }}
className="ml-1 text-dark-600 hover:text-accent-400 transition-colors"
onClick={(e) => {
e.stopPropagation();
copyToClipboard(String(ticket.user!.telegram_id));
}}
className="ml-1 text-dark-600 transition-colors hover:text-accent-400"
title={t('admin.tickets.copyTelegramId')}
>
(TG: {ticket.user!.telegram_id})
</button>
)}
{' '}| {new Date(ticket.updated_at).toLocaleDateString()}
)}{' '}
| {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 className="mt-1 truncate text-xs text-dark-600">
{ticket.last_message.is_from_admin
? t('admin.tickets.you')
: t('admin.tickets.user')}
: {ticket.last_message.message_text.substring(0, 50)}...
</div>
)}
</button>
@@ -298,19 +361,21 @@ export default function AdminTickets() {
)}
{ticketsData && ticketsData.pages > 1 && (
<div className="flex justify-center items-center gap-3 mt-4 pt-4 border-t border-dark-800/50">
<div className="mt-4 flex items-center justify-center gap-3 border-t border-dark-800/50 pt-4">
<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"
className="btn-secondary px-3 py-1.5 text-sm disabled:opacity-50"
>
{t('common.back')}
</button>
<span className="text-sm text-dark-400">{page} / {ticketsData.pages}</span>
<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"
className="btn-secondary px-3 py-1.5 text-sm disabled:opacity-50"
>
{t('common.next')}
</button>
@@ -319,59 +384,74 @@ export default function AdminTickets() {
</div>
{/* Ticket Detail */}
<div className="lg:col-span-2 card">
<div className="card lg:col-span-2">
{!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" />
<div className="flex h-64 flex-col items-center justify-center">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">
<svg
className="h-8 w-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 className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : selectedTicket ? (
<div className="flex flex-col h-full">
<div className="flex h-full flex-col">
{/* Header */}
<div className="border-b border-dark-800/50 pb-4 mb-4">
<div className="flex justify-between items-start mb-3">
<div className="mb-4 border-b border-dark-800/50 pb-4">
<div className="mb-3 flex items-start justify-between">
<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)}`)}
{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">
<div className="mb-4 text-sm text-dark-500">
{t('admin.tickets.from')}: {formatUser(selectedTicket)}
{selectedTicket.user?.telegram_id && (
<button
onClick={() => copyToClipboard(String(selectedTicket.user!.telegram_id))}
className="ml-1 px-2 py-0.5 text-xs bg-dark-700 hover:bg-dark-600 rounded transition-colors"
className="ml-1 rounded bg-dark-700 px-2 py-0.5 text-xs transition-colors hover:bg-dark-600"
title={t('admin.tickets.copyTelegramId')}
>
TG: {selectedTicket.user!.telegram_id}
</button>
)}
{' '}| {t('admin.tickets.created')}: {new Date(selectedTicket.created_at).toLocaleString()}
)}{' '}
| {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 })}
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 ${
className={`rounded-lg border px-3 py-1.5 text-xs transition-all ${
selectedTicket.status === s
? 'bg-accent-500/20 border-accent-500/50 text-accent-400'
? 'border-accent-500/50 bg-accent-500/20 text-accent-400'
: 'border-dark-700/50 text-dark-400 hover:border-dark-600 hover:text-dark-200'
} disabled:opacity-50`}
>
@@ -382,25 +462,29 @@ export default function AdminTickets() {
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto space-y-4 max-h-[400px] mb-4 scrollbar-hide">
<div className="scrollbar-hide mb-4 max-h-[400px] flex-1 space-y-4 overflow-y-auto">
{selectedTicket.messages.map((msg) => (
<div
key={msg.id}
className={`p-4 rounded-xl ${
className={`rounded-xl p-4 ${
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'
? 'ml-4 border border-accent-500/20 bg-accent-500/10'
: 'mr-4 border border-dark-700/30 bg-dark-800/50'
}`}
>
<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')}
<div className="mb-2 flex items-center justify-between">
<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>
<p className="whitespace-pre-wrap text-dark-200">{msg.message_text}</p>
<AdminMessageMedia message={msg} t={t} />
</div>
))}
@@ -416,7 +500,7 @@ export default function AdminTickets() {
rows={3}
className="input resize-none"
/>
<div className="flex justify-end mt-3">
<div className="mt-3 flex justify-end">
<button
type="submit"
disabled={!replyText.trim() || replyMutation.isPending}
@@ -424,7 +508,7 @@ export default function AdminTickets() {
>
{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" />
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
{t('common.loading')}
</span>
) : (
@@ -442,17 +526,17 @@ export default function AdminTickets() {
{/* Settings Modal */}
{showSettings && <TicketSettingsModal onClose={() => setShowSettings(false)} />}
</div>
)
);
}
function TicketSettingsModal({ onClose }: { onClose: () => void }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { t } = useTranslation();
const queryClient = useQueryClient();
const { data: settings, isLoading } = useQuery({
queryKey: ['ticket-settings'],
queryFn: adminApi.getTicketSettings,
})
});
const [formData, setFormData] = useState({
sla_enabled: settings?.sla_enabled ?? true,
@@ -462,7 +546,7 @@ function TicketSettingsModal({ onClose }: { onClose: () => void }) {
support_system_mode: settings?.support_system_mode ?? 'both',
cabinet_user_notifications_enabled: settings?.cabinet_user_notifications_enabled ?? true,
cabinet_admin_notifications_enabled: settings?.cabinet_admin_notifications_enabled ?? true,
})
});
// Update form when settings load
useEffect(() => {
@@ -475,33 +559,42 @@ function TicketSettingsModal({ onClose }: { onClose: () => void }) {
support_system_mode: settings.support_system_mode,
cabinet_user_notifications_enabled: settings.cabinet_user_notifications_enabled ?? true,
cabinet_admin_notifications_enabled: settings.cabinet_admin_notifications_enabled ?? true,
})
});
}
}, [settings])
}, [settings]);
const updateMutation = useMutation({
mutationFn: adminApi.updateTicketSettings,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ticket-settings'] })
onClose()
queryClient.invalidateQueries({ queryKey: ['ticket-settings'] });
onClose();
},
})
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
updateMutation.mutate(formData)
}
e.preventDefault();
updateMutation.mutate(formData);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={onClose}>
<div className="card max-w-2xl w-full max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<div className="flex justify-between items-center mb-6">
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
>
<div
className="card max-h-[90vh] w-full max-w-2xl overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-6 flex items-center justify-between">
<h2 className="text-xl font-bold text-dark-50">{t('admin.tickets.settings')}</h2>
<button
onClick={onClose}
className="text-dark-400 hover:text-dark-200 transition-colors"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<button onClick={onClose} className="text-dark-400 transition-colors hover:text-dark-200">
<svg
className="h-6 w-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>
@@ -509,13 +602,13 @@ function TicketSettingsModal({ onClose }: { onClose: () => void }) {
{isLoading ? (
<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 className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Support System Mode */}
<div>
<label className="block text-sm font-medium text-dark-100 mb-2">
<label className="mb-2 block text-sm font-medium text-dark-100">
{t('admin.tickets.supportMode')}
</label>
<select
@@ -527,60 +620,82 @@ function TicketSettingsModal({ onClose }: { onClose: () => void }) {
<option value="tickets">{t('admin.tickets.modeTickets')}</option>
<option value="contact">{t('admin.tickets.modeContact')}</option>
</select>
<p className="text-xs text-dark-500 mt-1">{t('admin.tickets.supportModeDesc')}</p>
<p className="mt-1 text-xs text-dark-500">{t('admin.tickets.supportModeDesc')}</p>
</div>
{/* Cabinet Notifications */}
<div className="border-t border-dark-800/50 pt-6">
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('admin.tickets.cabinetNotifications')}</h3>
<h3 className="mb-4 text-lg font-semibold text-dark-100">
{t('admin.tickets.cabinetNotifications')}
</h3>
{/* User Notifications */}
<div className="mb-4">
<label className="flex items-center gap-3 cursor-pointer">
<label className="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
checked={formData.cabinet_user_notifications_enabled}
onChange={(e) => setFormData({ ...formData, cabinet_user_notifications_enabled: e.target.checked })}
className="w-5 h-5 rounded border-dark-700 bg-dark-800 text-accent-500 focus:ring-2 focus:ring-accent-500 focus:ring-offset-0"
onChange={(e) =>
setFormData({
...formData,
cabinet_user_notifications_enabled: e.target.checked,
})
}
className="h-5 w-5 rounded border-dark-700 bg-dark-800 text-accent-500 focus:ring-2 focus:ring-accent-500 focus:ring-offset-0"
/>
<div>
<div className="text-dark-100 font-medium">{t('admin.tickets.userNotificationsEnabled')}</div>
<div className="text-sm text-dark-500">{t('admin.tickets.userNotificationsEnabledDesc')}</div>
<div className="font-medium text-dark-100">
{t('admin.tickets.userNotificationsEnabled')}
</div>
<div className="text-sm text-dark-500">
{t('admin.tickets.userNotificationsEnabledDesc')}
</div>
</div>
</label>
</div>
{/* Admin Notifications */}
<div>
<label className="flex items-center gap-3 cursor-pointer">
<label className="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
checked={formData.cabinet_admin_notifications_enabled}
onChange={(e) => setFormData({ ...formData, cabinet_admin_notifications_enabled: e.target.checked })}
className="w-5 h-5 rounded border-dark-700 bg-dark-800 text-accent-500 focus:ring-2 focus:ring-accent-500 focus:ring-offset-0"
onChange={(e) =>
setFormData({
...formData,
cabinet_admin_notifications_enabled: e.target.checked,
})
}
className="h-5 w-5 rounded border-dark-700 bg-dark-800 text-accent-500 focus:ring-2 focus:ring-accent-500 focus:ring-offset-0"
/>
<div>
<div className="text-dark-100 font-medium">{t('admin.tickets.adminNotificationsEnabled')}</div>
<div className="text-sm text-dark-500">{t('admin.tickets.adminNotificationsEnabledDesc')}</div>
<div className="font-medium text-dark-100">
{t('admin.tickets.adminNotificationsEnabled')}
</div>
<div className="text-sm text-dark-500">
{t('admin.tickets.adminNotificationsEnabledDesc')}
</div>
</div>
</label>
</div>
</div>
<div className="border-t border-dark-800/50 pt-6">
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('admin.tickets.slaSettings')}</h3>
<h3 className="mb-4 text-lg font-semibold text-dark-100">
{t('admin.tickets.slaSettings')}
</h3>
{/* SLA Enabled */}
<div>
<label className="flex items-center gap-3 cursor-pointer">
<label className="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
checked={formData.sla_enabled}
onChange={(e) => setFormData({ ...formData, sla_enabled: e.target.checked })}
className="w-5 h-5 rounded border-dark-700 bg-dark-800 text-accent-500 focus:ring-2 focus:ring-accent-500 focus:ring-offset-0"
className="h-5 w-5 rounded border-dark-700 bg-dark-800 text-accent-500 focus:ring-2 focus:ring-accent-500 focus:ring-offset-0"
/>
<div>
<div className="text-dark-100 font-medium">{t('admin.tickets.slaEnabled')}</div>
<div className="font-medium text-dark-100">{t('admin.tickets.slaEnabled')}</div>
<div className="text-sm text-dark-500">{t('admin.tickets.slaEnabledDesc')}</div>
</div>
</label>
@@ -589,7 +704,7 @@ function TicketSettingsModal({ onClose }: { onClose: () => void }) {
{/* SLA Minutes */}
<div>
<label className="block text-sm font-medium text-dark-100 mb-2">
<label className="mb-2 block text-sm font-medium text-dark-100">
{t('admin.tickets.slaMinutes')}
</label>
<input
@@ -597,16 +712,18 @@ function TicketSettingsModal({ onClose }: { onClose: () => void }) {
min="1"
max="1440"
value={formData.sla_minutes}
onChange={(e) => setFormData({ ...formData, sla_minutes: parseInt(e.target.value) })}
onChange={(e) =>
setFormData({ ...formData, sla_minutes: parseInt(e.target.value) })
}
className="input"
disabled={!formData.sla_enabled}
/>
<p className="text-xs text-dark-500 mt-1">{t('admin.tickets.slaMinutesDesc')}</p>
<p className="mt-1 text-xs text-dark-500">{t('admin.tickets.slaMinutesDesc')}</p>
</div>
{/* Check Interval */}
<div>
<label className="block text-sm font-medium text-dark-100 mb-2">
<label className="mb-2 block text-sm font-medium text-dark-100">
{t('admin.tickets.checkInterval')}
</label>
<input
@@ -614,16 +731,18 @@ function TicketSettingsModal({ onClose }: { onClose: () => void }) {
min="30"
max="600"
value={formData.sla_check_interval_seconds}
onChange={(e) => setFormData({ ...formData, sla_check_interval_seconds: parseInt(e.target.value) })}
onChange={(e) =>
setFormData({ ...formData, sla_check_interval_seconds: parseInt(e.target.value) })
}
className="input"
disabled={!formData.sla_enabled}
/>
<p className="text-xs text-dark-500 mt-1">{t('admin.tickets.checkIntervalDesc')}</p>
<p className="mt-1 text-xs text-dark-500">{t('admin.tickets.checkIntervalDesc')}</p>
</div>
{/* Reminder Cooldown */}
<div>
<label className="block text-sm font-medium text-dark-100 mb-2">
<label className="mb-2 block text-sm font-medium text-dark-100">
{t('admin.tickets.reminderCooldown')}
</label>
<input
@@ -631,30 +750,29 @@ function TicketSettingsModal({ onClose }: { onClose: () => void }) {
min="1"
max="120"
value={formData.sla_reminder_cooldown_minutes}
onChange={(e) => setFormData({ ...formData, sla_reminder_cooldown_minutes: parseInt(e.target.value) })}
onChange={(e) =>
setFormData({
...formData,
sla_reminder_cooldown_minutes: parseInt(e.target.value),
})
}
className="input"
disabled={!formData.sla_enabled}
/>
<p className="text-xs text-dark-500 mt-1">{t('admin.tickets.reminderCooldownDesc')}</p>
<p className="mt-1 text-xs text-dark-500">
{t('admin.tickets.reminderCooldownDesc')}
</p>
</div>
{/* Buttons */}
<div className="flex gap-3 justify-end pt-4 border-t border-dark-800/50">
<button
type="button"
onClick={onClose}
className="btn-secondary"
>
<div className="flex justify-end gap-3 border-t border-dark-800/50 pt-4">
<button type="button" onClick={onClose} className="btn-secondary">
{t('common.cancel')}
</button>
<button
type="submit"
disabled={updateMutation.isPending}
className="btn-primary"
>
<button type="submit" disabled={updateMutation.isPending} className="btn-primary">
{updateMutation.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" />
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
{t('common.saving')}
</span>
) : (
@@ -664,7 +782,7 @@ function TicketSettingsModal({ onClose }: { onClose: () => void }) {
</div>
{updateMutation.isError && (
<div className="p-3 rounded-lg bg-error-500/10 border border-error-500/30 text-error-400 text-sm">
<div className="rounded-lg border border-error-500/30 bg-error-500/10 p-3 text-sm text-error-400">
{t('admin.tickets.settingsUpdateError')}
</div>
)}
@@ -672,5 +790,5 @@ function TicketSettingsModal({ onClose }: { onClose: () => void }) {
)}
</div>
</div>
)
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,45 +1,67 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import { adminWheelApi, type WheelPrizeAdmin, type CreateWheelPrizeData } from '../api/wheel'
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { adminWheelApi, type WheelPrizeAdmin, type CreateWheelPrizeData } from '../api/wheel';
// Icons
const BackIcon = () => (
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
)
);
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 className="h-5 w-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 className="h-5 w-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 className="h-5 w-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}>
<svg className="h-5 w-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 className="h-5 w-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: '📅' },
@@ -47,73 +69,73 @@ const PRIZE_TYPE_KEYS = [
{ value: 'traffic_gb', key: 'traffic_gb', emoji: '📊' },
{ value: 'promocode', key: 'promocode', emoji: '🎟️' },
{ value: 'nothing', key: 'nothing', emoji: '😔' },
]
];
type Tab = 'settings' | 'prizes' | 'statistics'
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)
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'] })
queryClient.invalidateQueries({ queryKey: ['admin-wheel-config'] });
},
})
});
// Prize mutations
const createPrizeMutation = useMutation({
mutationFn: adminWheelApi.createPrize,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-wheel-config'] })
setIsCreating(false)
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)
queryClient.invalidateQueries({ queryKey: ['admin-wheel-config'] });
setEditingPrize(null);
},
})
});
const deletePrizeMutation = useMutation({
mutationFn: adminWheelApi.deletePrize,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-wheel-config'] })
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 className="flex min-h-[400px] items-center justify-center">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
)
);
}
if (!config) {
return <div className="text-center py-12 text-dark-400">{t('wheel.errors.loadFailed')}</div>
return <div className="py-12 text-center text-dark-400">{t('wheel.errors.loadFailed')}</div>;
}
return (
@@ -123,18 +145,18 @@ export default function AdminWheel() {
<div className="flex items-center gap-3">
<Link
to="/admin"
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 hover:border-dark-600 transition-colors"
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
>
<BackIcon />
</Link>
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">
{t('admin.wheel.title')}
</h1>
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('admin.wheel.title')}</h1>
</div>
<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'
}`}>
<span
className={`rounded-full px-3 py-1 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>
@@ -144,9 +166,9 @@ export default function AdminWheel() {
<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 ${
className={`flex items-center gap-2 rounded-t-lg px-4 py-2 transition-colors ${
activeTab === 'settings'
? 'bg-dark-800 text-accent-400 border-b-2 border-accent-500'
? 'border-b-2 border-accent-500 bg-dark-800 text-accent-400'
: 'text-dark-400 hover:text-dark-200'
}`}
>
@@ -155,9 +177,9 @@ export default function AdminWheel() {
</button>
<button
onClick={() => setActiveTab('prizes')}
className={`flex items-center gap-2 px-4 py-2 rounded-t-lg transition-colors ${
className={`flex items-center gap-2 rounded-t-lg px-4 py-2 transition-colors ${
activeTab === 'prizes'
? 'bg-dark-800 text-accent-400 border-b-2 border-accent-500'
? 'border-b-2 border-accent-500 bg-dark-800 text-accent-400'
: 'text-dark-400 hover:text-dark-200'
}`}
>
@@ -166,9 +188,9 @@ export default function AdminWheel() {
</button>
<button
onClick={() => setActiveTab('statistics')}
className={`flex items-center gap-2 px-4 py-2 rounded-t-lg transition-colors ${
className={`flex items-center gap-2 rounded-t-lg px-4 py-2 transition-colors ${
activeTab === 'statistics'
? 'bg-dark-800 text-accent-400 border-b-2 border-accent-500'
? 'border-b-2 border-accent-500 bg-dark-800 text-accent-400'
: 'text-dark-400 hover:text-dark-200'
}`}
>
@@ -179,37 +201,41 @@ export default function AdminWheel() {
{/* Settings Tab */}
{activeTab === 'settings' && (
<div className="card p-6 space-y-6">
<div className="card space-y-6 p-6">
{/* Enable toggle */}
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-dark-100">{t('admin.wheel.settings.enableWheel')}</h3>
<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">
<label className="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
checked={config.is_enabled}
onChange={(e) => updateConfigMutation.mutate({ is_enabled: e.target.checked })}
className="sr-only peer"
className="peer sr-only"
/>
<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>
<div className="peer h-6 w-11 rounded-full bg-dark-700 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-white after:transition-all after:content-[''] peer-checked:bg-accent-500 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none"></div>
</label>
</div>
<hr className="border-dark-700" />
{/* Spin costs */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
<label className="mb-1 block text-sm font-medium text-dark-300">
{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 })}
onChange={(e) =>
updateConfigMutation.mutate({ spin_cost_stars: parseInt(e.target.value) || 1 })
}
min={1}
max={1000}
className="input flex-1"
@@ -218,7 +244,9 @@ export default function AdminWheel() {
<input
type="checkbox"
checked={config.spin_cost_stars_enabled}
onChange={(e) => updateConfigMutation.mutate({ spin_cost_stars_enabled: e.target.checked })}
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>
@@ -227,14 +255,16 @@ export default function AdminWheel() {
</div>
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
<label className="mb-1 block text-sm font-medium text-dark-300">
{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 })}
onChange={(e) =>
updateConfigMutation.mutate({ spin_cost_days: parseInt(e.target.value) || 1 })
}
min={1}
max={30}
className="input flex-1"
@@ -243,7 +273,9 @@ export default function AdminWheel() {
<input
type="checkbox"
checked={config.spin_cost_days_enabled}
onChange={(e) => updateConfigMutation.mutate({ spin_cost_days_enabled: e.target.checked })}
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>
@@ -255,9 +287,9 @@ export default function AdminWheel() {
<hr className="border-dark-700" />
{/* RTP and limits */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('admin.wheel.settings.rtpPercent')}
</label>
<input
@@ -265,7 +297,9 @@ export default function AdminWheel() {
min={0}
max={100}
value={config.rtp_percent}
onChange={(e) => updateConfigMutation.mutate({ rtp_percent: parseInt(e.target.value) })}
onChange={(e) =>
updateConfigMutation.mutate({ rtp_percent: parseInt(e.target.value) })
}
className="w-full"
/>
<div className="flex justify-between text-sm text-dark-400">
@@ -276,13 +310,15 @@ export default function AdminWheel() {
</div>
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
<label className="mb-1 block text-sm font-medium text-dark-300">
{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 })}
onChange={(e) =>
updateConfigMutation.mutate({ daily_spin_limit: parseInt(e.target.value) || 0 })
}
min={0}
max={100}
className="input w-full"
@@ -290,15 +326,19 @@ export default function AdminWheel() {
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
<label className="mb-1 block text-sm font-medium text-dark-300">
{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 })}
onChange={(e) =>
updateConfigMutation.mutate({
min_subscription_days_for_day_payment: parseInt(e.target.value) || 1,
})
}
min={1}
max={30}
className="input w-full"
@@ -306,7 +346,7 @@ export default function AdminWheel() {
</div>
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('admin.wheel.settings.promoPrefix')}
</label>
<input
@@ -337,13 +377,10 @@ export default function AdminWheel() {
{/* 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 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"
className="flex h-12 w-12 items-center justify-center rounded-lg text-2xl"
style={{ backgroundColor: prize.color + '30' }}
>
{prize.emoji}
@@ -353,20 +390,18 @@ export default function AdminWheel() {
<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)}
{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"
>
<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)
deletePrizeMutation.mutate(prize.id);
}
}}
className="btn-ghost text-red-400"
@@ -380,7 +415,7 @@ export default function AdminWheel() {
</div>
{config.prizes.length === 0 && (
<div className="text-center py-12 text-dark-400">
<div className="py-12 text-center text-dark-400">
{t('admin.wheel.prizes.noPrizes')}
</div>
)}
@@ -391,7 +426,7 @@ export default function AdminWheel() {
{activeTab === 'statistics' && stats && (
<div className="space-y-4">
{/* Stats cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="grid grid-cols-2 gap-4 md:grid-cols-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>
@@ -409,13 +444,18 @@ export default function AdminWheel() {
<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'
}`}>
<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}%)
{t('admin.wheel.statistics.actualRtp')} ({t('admin.wheel.statistics.targetRtp')}:{' '}
{stats.configured_rtp_percent}%)
</div>
</div>
</div>
@@ -423,12 +463,16 @@ export default function AdminWheel() {
{/* 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>
<h3 className="mb-3 font-semibold text-dark-100">
{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>
<span className="text-dark-100">
{prize.count} {t('admin.wheel.statistics.times')}
</span>
</div>
))}
</div>
@@ -438,13 +482,13 @@ export default function AdminWheel() {
{/* 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>
<h3 className="mb-3 font-semibold text-dark-100">
{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-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>
@@ -461,20 +505,20 @@ export default function AdminWheel() {
<PrizeModal
prize={editingPrize}
onClose={() => {
setIsCreating(false)
setEditingPrize(null)
setIsCreating(false);
setEditingPrize(null);
}}
onSave={(data) => {
if (editingPrize) {
updatePrizeMutation.mutate({ id: editingPrize.id, data })
updatePrizeMutation.mutate({ id: editingPrize.id, data });
} else {
createPrizeMutation.mutate(data as CreateWheelPrizeData)
createPrizeMutation.mutate(data as CreateWheelPrizeData);
}
}}
/>
)}
</div>
)
);
}
// Prize Modal Component
@@ -483,11 +527,11 @@ function PrizeModal({
onClose,
onSave,
}: {
prize: WheelPrizeAdmin | null
onClose: () => void
onSave: (data: Partial<WheelPrizeAdmin>) => void
prize: WheelPrizeAdmin | null;
onClose: () => void;
onSave: (data: Partial<WheelPrizeAdmin>) => void;
}) {
const { t } = useTranslation()
const { t } = useTranslation();
const [formData, setFormData] = useState({
prize_type: prize?.prize_type || 'balance_bonus',
prize_value: prize?.prize_value || 0,
@@ -500,24 +544,26 @@ function PrizeModal({
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)
}
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">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
<div className="card max-h-[90vh] w-full max-w-md overflow-y-auto p-6">
<h2 className="mb-4 text-xl font-bold text-dark-50">
{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>
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('admin.wheel.prizes.fields.type')}
</label>
<select
value={formData.prize_type}
onChange={(e) => setFormData({ ...formData, prize_type: e.target.value })}
@@ -533,7 +579,9 @@ function PrizeModal({
{/* Display name */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">{t('admin.wheel.prizes.fields.displayName')}</label>
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('admin.wheel.prizes.fields.displayName')}
</label>
<input
type="text"
value={formData.display_name}
@@ -548,13 +596,21 @@ function PrizeModal({
{/* 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 className="mb-1 block text-sm font-medium text-dark-300">
{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 })}
onChange={(e) =>
setFormData({ ...formData, prize_value: parseInt(e.target.value) || 0 })
}
min={0}
className="input w-full"
/>
@@ -563,17 +619,19 @@ function PrizeModal({
{/* Prize value in kopeks (for RTP calculation) */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">
<label className="mb-1 block text-sm font-medium text-dark-300">
{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 })}
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">
<p className="mt-1 text-xs text-dark-500">
= {(formData.prize_value_kopeks / 100).toFixed(2)} RUB
</p>
</div>
@@ -581,7 +639,9 @@ function PrizeModal({
{/* 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>
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('admin.wheel.prizes.fields.emoji')}
</label>
<input
type="text"
value={formData.emoji}
@@ -591,13 +651,15 @@ function PrizeModal({
/>
</div>
<div>
<label className="block text-sm font-medium text-dark-300 mb-1">{t('admin.wheel.prizes.fields.color')}</label>
<label className="mb-1 block text-sm font-medium text-dark-300">
{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"
className="h-10 w-12 cursor-pointer rounded"
/>
<input
type="text"
@@ -619,29 +681,45 @@ function PrizeModal({
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>
<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">
<div className="space-y-3 rounded-lg bg-dark-800 p-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>
<label className="mb-1 block text-sm text-dark-400">
{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 })}
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>
<label className="mb-1 block text-sm text-dark-400">
{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 })}
onChange={(e) =>
setFormData({
...formData,
promo_subscription_days: parseInt(e.target.value) || 0,
})
}
min={0}
className="input w-full"
/>
@@ -661,5 +739,5 @@ function PrizeModal({
</form>
</div>
</div>
)
);
}

View File

@@ -1,17 +1,17 @@
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'
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()
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({
@@ -19,121 +19,124 @@ export default function 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 [isHistoryOpen, setIsHistoryOpen] = useState(false)
refreshUser();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
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 [isHistoryOpen, setIsHistoryOpen] = useState(false);
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 normalizeType = (type: string) => type?.toUpperCase?.() ?? type;
const getTypeBadge = (type: string) => {
switch (normalizeType(type)) {
case 'DEPOSIT':
return 'badge-success'
return 'badge-success';
case 'SUBSCRIPTION_PAYMENT':
return 'badge-info'
return 'badge-info';
case 'REFERRAL_REWARD':
return 'badge-warning'
return 'badge-warning';
case 'WITHDRAWAL':
return 'badge-error'
return 'badge-error';
default:
return 'badge-neutral'
return 'badge-neutral';
}
}
};
const getTypeLabel = (type: string) => {
switch (normalizeType(type)) {
case 'DEPOSIT':
return t('balance.deposit')
return t('balance.deposit');
case 'SUBSCRIPTION_PAYMENT':
return t('balance.subscriptionPayment')
return t('balance.subscriptionPayment');
case 'REFERRAL_REWARD':
return t('balance.referralReward')
return t('balance.referralReward');
case 'WITHDRAWAL':
return t('balance.withdrawal')
return t('balance.withdrawal');
default:
return type
return type;
}
}
};
const handlePromocodeActivate = async () => {
if (!promocode.trim()) return
if (!promocode.trim()) return;
setPromocodeLoading(true)
setPromocodeError(null)
setPromocodeSuccess(null)
setPromocodeLoading(true);
setPromocodeError(null);
setPromocodeSuccess(null);
try {
const result = await balanceApi.activatePromocode(promocode.trim())
const result = await balanceApi.activatePromocode(promocode.trim());
if (result.success) {
const bonusAmount = result.balance_after - result.balance_before
const bonusAmount = result.balance_after - result.balance_before;
setPromocodeSuccess({
message: result.bonus_description || t('balance.promocode.success'),
amount: bonusAmount,
})
setTransactionsPage(1)
setPromocode('')
});
setTransactionsPage(1);
setPromocode('');
// Refresh balance and transactions
await refetchBalance()
await refreshUser()
queryClient.invalidateQueries({ queryKey: ['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'
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}`))
? '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)
setPromocodeLoading(false);
}
}
};
return (
<div className="space-y-6">
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('balance.title')}</h1>
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('balance.title')}</h1>
{/* Balance Card */}
<div className="bento-card bento-card-glow bg-gradient-to-br from-accent-500/10 to-transparent">
<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">
<div className="mb-2 text-sm text-dark-400">{t('balance.currentBalance')}</div>
<div className="text-4xl font-bold text-dark-50 sm:text-5xl">
{formatAmount(balanceData?.balance_rubles || 0)}
<span className="text-2xl text-dark-400 ml-2">{currencySymbol}</span>
<span className="ml-2 text-2xl text-dark-400">{currencySymbol}</span>
</div>
</div>
{/* Promo Code Section */}
<div className="bento-card">
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('balance.promocode.title')}</h2>
<h2 className="mb-4 text-lg font-semibold text-dark-100">{t('balance.promocode.title')}</h2>
<div className="flex gap-3">
<input
type="text"
@@ -147,21 +150,25 @@ export default function Balance() {
<button
onClick={handlePromocodeActivate}
disabled={!promocode.trim() || promocodeLoading}
className="btn-primary px-6 whitespace-nowrap"
className="btn-primary whitespace-nowrap px-6"
>
{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">
<div className="mt-3 rounded-lg border border-error-500/30 bg-error-500/10 p-3 text-sm text-error-400">
{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="mt-3 rounded-lg border border-success-500/30 bg-success-500/10 p-3 text-sm text-success-400">
<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 className="mt-1">
{t('balance.promocode.balanceAdded', {
amount: promocodeSuccess.amount.toFixed(2),
})}
</div>
)}
</div>
)}
@@ -170,33 +177,38 @@ export default function Balance() {
{/* Payment Methods */}
{paymentMethods && paymentMethods.length > 0 && (
<div className="bento-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">
<h2 className="mb-4 text-lg font-semibold text-dark-100">{t('balance.topUpBalance')}</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-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: '' })
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={`bento-card-hover p-4 text-left transition-all ${
method.is_available
? 'cursor-pointer'
: '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>
)
<button
key={method.id}
disabled={!method.is_available}
onClick={() => method.is_available && setSelectedMethod(method)}
className={`bento-card-hover p-4 text-left transition-all ${
method.is_available ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'
}`}
>
<div className="font-semibold text-dark-100">{translatedName || method.name}</div>
{(translatedDesc || method.description) && (
<div className="mt-1 text-sm text-dark-500">
{translatedDesc || method.description}
</div>
)}
<div className="mt-3 text-xs text-dark-600">
{formatAmount(method.min_amount_kopeks / 100, 0)} {' '}
{formatAmount(method.max_amount_kopeks / 100, 0)} {currencySymbol}
</div>
</button>
);
})}
</div>
</div>
@@ -205,11 +217,11 @@ export default function Balance() {
<div className="bento-card overflow-hidden">
<button
onClick={() => setIsHistoryOpen(!isHistoryOpen)}
className="w-full flex items-center justify-between text-left"
className="flex w-full items-center justify-between text-left"
>
<h2 className="text-lg font-semibold text-dark-100">{t('balance.transactionHistory')}</h2>
<svg
className={`w-5 h-5 text-dark-400 transition-transform duration-200 ${isHistoryOpen ? 'rotate-180' : ''}`}
className={`h-5 w-5 text-dark-400 transition-transform duration-200 ${isHistoryOpen ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -223,26 +235,24 @@ export default function Balance() {
<div className="mt-4">
{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 className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : transactions?.items && transactions.items.length > 0 ? (
<div className="space-y-3">
{transactions.items.map((tx) => {
const isPositive = tx.amount_rubles >= 0
const displayAmount = Math.abs(tx.amount_rubles)
const sign = isPositive ? '+' : '-'
const colorClass = isPositive ? 'text-success-400' : 'text-error-400'
const isPositive = tx.amount_rubles >= 0;
const displayAmount = Math.abs(tx.amount_rubles);
const sign = isPositive ? '+' : '-';
const colorClass = isPositive ? 'text-success-400' : 'text-error-400';
return (
<div
key={tx.id}
className="flex items-center justify-between p-4 rounded-xl bg-dark-800/30 border border-dark-700/30"
className="flex items-center justify-between rounded-xl border border-dark-700/30 bg-dark-800/30 p-4"
>
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<span className={getTypeBadge(tx.type)}>
{getTypeLabel(tx.type)}
</span>
<div className="mb-1 flex items-center gap-3">
<span className={getTypeBadge(tx.type)}>{getTypeLabel(tx.type)}</span>
<span className="text-xs text-dark-500">
{new Date(tx.created_at).toLocaleDateString()}
</span>
@@ -252,17 +262,28 @@ export default function Balance() {
)}
</div>
<div className={`text-lg font-semibold ${colorClass}`}>
{sign}{formatAmount(displayAmount)} {currencySymbol}
{sign}
{formatAmount(displayAmount)} {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" />
<div className="py-12 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">
<svg
className="h-8 w-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>
@@ -270,13 +291,13 @@ export default function Balance() {
)}
{transactions && transactions.pages > 1 && (
<div className="mt-4 flex items-center gap-3 flex-wrap text-sm text-dark-500">
<div className="mt-4 flex flex-wrap items-center gap-3 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' : ''
className={`btn-secondary min-w-[120px] flex-1 text-xs sm:flex-none sm:text-sm ${
transactions.page <= 1 ? 'cursor-not-allowed opacity-50' : ''
}`}
>
{t('common.back')}
@@ -288,12 +309,12 @@ export default function Balance() {
type="button"
onClick={() =>
setTransactionsPage((prev) =>
transactions.pages ? Math.min(transactions.pages, prev + 1) : prev + 1
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' : ''
className={`btn-secondary min-w-[120px] flex-1 text-xs sm:flex-none sm:text-sm ${
transactions.page >= transactions.pages ? 'cursor-not-allowed opacity-50' : ''
}`}
>
{t('common.next')}
@@ -306,11 +327,8 @@ export default function Balance() {
{/* TopUp Modal */}
{selectedMethod && (
<TopUpModal
method={selectedMethod}
onClose={() => setSelectedMethod(null)}
/>
<TopUpModal method={selectedMethod} onClose={() => setSelectedMethod(null)} />
)}
</div>
)
);
}

View File

@@ -1,110 +1,132 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { contestsApi, ContestInfo, ContestGameData } from '../api/contests'
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 className="h-6 w-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 className="h-5 w-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 { 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({
const {
data: contests,
isLoading,
error,
} = useQuery({
queryKey: ['contests'],
queryFn: contestsApi.getContests,
})
});
const getGameMutation = useMutation({
mutationFn: contestsApi.getContestGame,
onSuccess: (data) => {
setGameData(data)
setResult(null)
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'] })
setResult(data);
queryClient.invalidateQueries({ queryKey: ['contests'] });
},
})
});
const handlePlayContest = async (contest: ContestInfo) => {
setSelectedContest(contest)
getGameMutation.mutate(contest.id)
}
setSelectedContest(contest);
getGameMutation.mutate(contest.id);
};
const handleSubmitAnswer = (answer: string) => {
if (gameData) {
submitAnswerMutation.mutate({ roundId: gameData.round_id, answer })
submitAnswerMutation.mutate({ roundId: gameData.round_id, answer });
}
}
};
const handleCloseGame = () => {
setSelectedContest(null)
setGameData(null)
setResult(null)
}
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 className="flex min-h-64 items-center justify-center">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
)
);
}
if (error) {
return (
<div className="card bg-red-500/10 border-red-500/20">
<div className="card border-red-500/20 bg-red-500/10">
<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>
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('contests.title')}</h1>
</div>
{/* Game Modal */}
{selectedContest && (
<div className="fixed inset-0 z-[60] bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bento-card max-w-lg w-full max-h-[80vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex justify-between items-center mb-4">
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
<div
className="bento-card max-h-[80vh] w-full max-w-lg overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<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 className="h-6 w-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 className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</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'}`}>
<div
className={`mb-4 rounded-lg p-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>
)}
@@ -116,12 +138,14 @@ export default function Contests() {
{/* 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) => (
{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"
className="flex aspect-square items-center justify-center rounded-lg bg-dark-700 text-2xl transition-colors hover:bg-dark-600"
>
{gameData.game_type === 'locks' ? '🔒' : '🎛'}
</button>
@@ -136,7 +160,7 @@ export default function Contests() {
key={i}
onClick={() => handleSubmitAnswer(flag)}
disabled={submitAnswerMutation.isPending}
className="p-3 bg-dark-700 hover:bg-dark-600 rounded-lg text-2xl transition-colors"
className="rounded-lg bg-dark-700 p-3 text-2xl transition-colors hover:bg-dark-600"
>
{flag}
</button>
@@ -148,7 +172,7 @@ export default function Contests() {
<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"
className="w-full rounded-lg bg-accent-500 py-4 text-lg font-bold transition-colors hover:bg-accent-600"
>
{gameData.game_data.button_text || t('contests.imHere')}
</button>
@@ -157,25 +181,27 @@ export default function Contests() {
{['cipher', 'emoji', 'anagram'].includes(gameData.game_type) && (
<form
onSubmit={(e) => {
e.preventDefault()
const input = e.currentTarget.elements.namedItem('answer') as HTMLInputElement
handleSubmitAnswer(input.value)
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">
<div className="rounded-lg bg-dark-700 p-4 text-center font-mono text-2xl">
{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"
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-4 py-3 focus:border-accent-500 focus:outline-none"
/>
<button
type="submit"
disabled={submitAnswerMutation.isPending}
className="w-full btn-primary"
className="btn-primary w-full"
>
{t('contests.submit')}
</button>
@@ -185,7 +211,7 @@ export default function Contests() {
)}
{result && (
<button onClick={handleCloseGame} className="w-full btn-secondary mt-4">
<button onClick={handleCloseGame} className="btn-secondary mt-4 w-full">
{t('common.close')}
</button>
)}
@@ -200,27 +226,26 @@ export default function Contests() {
<div key={contest.id} className="card">
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold text-lg">{contest.name}</h3>
<h3 className="text-lg font-semibold">{contest.name}</h3>
{contest.description && (
<p className="text-dark-400 text-sm mt-1">{contest.description}</p>
<p className="mt-1 text-sm text-dark-400">{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>
<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">
<button disabled className="btn-secondary w-full cursor-not-allowed opacity-50">
{t('contests.alreadyPlayed')}
</button>
) : (
<button
onClick={() => handlePlayContest(contest)}
className="w-full btn-primary"
>
<button onClick={() => handlePlayContest(contest)} className="btn-primary w-full">
{t('contests.play')}
</button>
)}
@@ -229,11 +254,11 @@ export default function Contests() {
))}
</div>
) : (
<div className="card text-center py-12">
<div className="card py-12 text-center">
<GamepadIcon />
<p className="text-dark-400 mt-4">{t('contests.noContests')}</p>
<p className="mt-4 text-dark-400">{t('contests.noContests')}</p>
</div>
)}
</div>
)
);
}

View File

@@ -1,62 +1,75 @@
import { useState, useEffect, useMemo, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
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 PromoOffersSection from '../components/PromoOffersSection'
import { useCurrency } from '../hooks/useCurrency'
import { useState, useEffect, useMemo, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
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 PromoOffersSection from '../components/PromoOffersSection';
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}>
<svg className="h-4 w-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 className="h-5 w-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}>
<svg className="h-5 w-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 RefreshIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
const RefreshIcon = ({ className = 'w-4 h-4' }: { className?: string }) => (
<svg className={className} 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" />
<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 SupportLottieIcon = () => (
<svg className="w-6 h-6" 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 className="h-6 w-6" 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>
)
);
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)
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()
}, [])
refreshUser();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Fetch balance from API with no caching
const { data: balanceData } = useQuery({
@@ -64,26 +77,30 @@ export default function Dashboard() {
queryFn: balanceApi.getBalance,
staleTime: 0,
refetchOnMount: 'always',
})
});
const { data: subscription, isLoading: subLoading, error: subError } = useQuery({
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({
@@ -91,29 +108,29 @@ export default function Dashboard() {
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'] })
queryClient.invalidateQueries({ queryKey: ['balance'] })
refreshUser()
setTrialError(null);
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['trial-info'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
refreshUser();
},
onError: (error: { response?: { data?: { detail?: string } } }) => {
setTrialError(error.response?.data?.detail || t('common.error'))
setTrialError(error.response?.data?.detail || t('common.error'));
},
})
});
// Traffic refresh state and mutation
const [trafficRefreshCooldown, setTrafficRefreshCooldown] = useState(0)
const [trafficRefreshCooldown, setTrafficRefreshCooldown] = useState(0);
const [trafficData, setTrafficData] = useState<{
traffic_used_gb: number
traffic_used_percent: number
is_unlimited: boolean
} | null>(null)
traffic_used_gb: number;
traffic_used_percent: number;
is_unlimited: boolean;
} | null>(null);
const refreshTrafficMutation = useMutation({
mutationFn: subscriptionApi.refreshTraffic,
@@ -122,77 +139,80 @@ export default function Dashboard() {
traffic_used_gb: data.traffic_used_gb,
traffic_used_percent: data.traffic_used_percent,
is_unlimited: data.is_unlimited,
})
});
// Save last refresh timestamp to localStorage
localStorage.setItem('traffic_refresh_ts', Date.now().toString())
localStorage.setItem('traffic_refresh_ts', Date.now().toString());
if (data.rate_limited && data.retry_after_seconds) {
setTrafficRefreshCooldown(data.retry_after_seconds)
setTrafficRefreshCooldown(data.retry_after_seconds);
} else {
setTrafficRefreshCooldown(30)
setTrafficRefreshCooldown(30);
}
queryClient.invalidateQueries({ queryKey: ['subscription'] })
queryClient.invalidateQueries({ queryKey: ['subscription'] });
},
onError: (error: { response?: { status?: number; headers?: { get?: (key: string) => string } } }) => {
onError: (error: {
response?: { status?: number; headers?: { get?: (key: string) => string } };
}) => {
if (error.response?.status === 429) {
const retryAfter = error.response.headers?.get?.('Retry-After')
setTrafficRefreshCooldown(retryAfter ? parseInt(retryAfter, 10) : 30)
const retryAfter = error.response.headers?.get?.('Retry-After');
setTrafficRefreshCooldown(retryAfter ? parseInt(retryAfter, 10) : 30);
}
},
})
});
// Cooldown timer
useEffect(() => {
if (trafficRefreshCooldown <= 0) return
if (trafficRefreshCooldown <= 0) return;
const timer = setInterval(() => {
setTrafficRefreshCooldown((prev) => Math.max(0, prev - 1))
}, 1000)
return () => clearInterval(timer)
}, [trafficRefreshCooldown])
setTrafficRefreshCooldown((prev) => Math.max(0, prev - 1));
}, 1000);
return () => clearInterval(timer);
}, [trafficRefreshCooldown]);
// Track if we've already triggered auto-refresh this session
const hasAutoRefreshed = useRef(false)
const hasAutoRefreshed = useRef(false);
// Auto-refresh traffic on mount (with 30s caching)
useEffect(() => {
if (!subscription) return
if (hasAutoRefreshed.current) return
hasAutoRefreshed.current = true
if (!subscription) return;
if (hasAutoRefreshed.current) return;
hasAutoRefreshed.current = true;
const lastRefresh = localStorage.getItem('traffic_refresh_ts')
const now = Date.now()
const cacheMs = 30 * 1000
const lastRefresh = localStorage.getItem('traffic_refresh_ts');
const now = Date.now();
const cacheMs = 30 * 1000;
if (lastRefresh && now - parseInt(lastRefresh, 10) < cacheMs) {
const elapsed = now - parseInt(lastRefresh, 10)
const remaining = Math.ceil((cacheMs - elapsed) / 1000)
const elapsed = now - parseInt(lastRefresh, 10);
const remaining = Math.ceil((cacheMs - elapsed) / 1000);
if (remaining > 0) {
setTrafficRefreshCooldown(remaining)
setTrafficRefreshCooldown(remaining);
}
return
return;
}
refreshTrafficMutation.mutate()
}, [subscription])
refreshTrafficMutation.mutate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subscription]);
const hasNoSubscription = !subscription && !subLoading && subError
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)
const timer = setTimeout(() => setShowOnboarding(true), 500);
return () => clearTimeout(timer);
}
}, [isOnboardingCompleted, subLoading, refLoading])
}, [isOnboardingCompleted, subLoading, refLoading]);
// Define onboarding steps based on available data
const onboardingSteps = useMemo(() => {
type Placement = 'top' | 'bottom' | 'left' | 'right'
type Placement = 'top' | 'bottom' | 'left' | 'right';
const steps: Array<{
target: string
title: string
description: string
placement: Placement
target: string;
title: string;
description: string;
placement: Placement;
}> = [
{
target: 'welcome',
@@ -212,7 +232,7 @@ export default function Dashboard() {
description: t('onboarding.steps.subscription.description'),
placement: 'bottom',
},
]
];
// Add connect devices step only if subscription exists
if (subscription?.subscription_url) {
@@ -221,7 +241,7 @@ export default function Dashboard() {
title: t('onboarding.steps.connectDevices.title'),
description: t('onboarding.steps.connectDevices.description'),
placement: 'bottom',
})
});
}
steps.push({
@@ -229,44 +249,44 @@ export default function Dashboard() {
title: t('onboarding.steps.quickActions.title'),
description: t('onboarding.steps.quickActions.description'),
placement: 'top',
})
});
return steps
}, [t, subscription])
return steps;
}, [t, subscription]);
const handleOnboardingComplete = () => {
setShowOnboarding(false)
completeOnboarding()
}
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'
}
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">
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">
{t('dashboard.welcome', { name: user?.first_name || user?.username || '' })}
</h1>
<p className="text-dark-400 mt-1">{t('dashboard.yourSubscription')}</p>
<p className="mt-1 text-dark-400">{t('dashboard.yourSubscription')}</p>
</div>
{/* Subscription Status - Main Card */}
{subLoading ? (
<div className="bento-card">
<div className="flex items-center justify-between mb-6">
<div className="mb-6 flex items-center justify-between">
<div className="skeleton h-6 w-24" />
<div className="skeleton h-6 w-16 rounded-full" />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div className="grid grid-cols-2 gap-6 md:grid-cols-4">
{[...Array(4)].map((_, i) => (
<div key={i}>
<div className="skeleton h-4 w-20 mb-2" />
<div className="skeleton mb-2 h-4 w-20" />
<div className="skeleton h-5 w-24" />
</div>
))}
@@ -281,47 +301,51 @@ export default function Dashboard() {
</div>
) : subscription ? (
<div className="bento-card">
<div className="flex items-center justify-between mb-6">
<div className="mb-6 flex items-center justify-between">
<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 className="grid grid-cols-2 gap-6 md:grid-cols-4">
<div>
<div className="text-sm text-dark-500 mb-1">{t('subscription.expiresAt')}</div>
<div className="text-dark-100 font-medium">
<div className="mb-1 text-sm text-dark-500">{t('subscription.expiresAt')}</div>
<div className="font-medium text-dark-100">
{new Date(subscription.end_date).toLocaleDateString()}
</div>
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<div className="mb-1 flex items-center gap-2">
<span className="text-sm text-dark-500">{t('subscription.traffic')}</span>
<button
onClick={() => refreshTrafficMutation.mutate()}
disabled={refreshTrafficMutation.isPending || trafficRefreshCooldown > 0}
className="p-1 rounded-full hover:bg-dark-700/50 text-dark-400 hover:text-accent-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title={trafficRefreshCooldown > 0 ? `${trafficRefreshCooldown}s` : t('common.refresh')}
className="rounded-full p-1 text-dark-400 transition-colors hover:bg-dark-700/50 hover:text-accent-400 disabled:cursor-not-allowed disabled:opacity-50"
title={
trafficRefreshCooldown > 0 ? `${trafficRefreshCooldown}s` : t('common.refresh')
}
>
<RefreshIcon className={`w-3.5 h-3.5 ${refreshTrafficMutation.isPending ? 'animate-spin' : ''}`} />
<RefreshIcon
className={`h-3.5 w-3.5 ${refreshTrafficMutation.isPending ? 'animate-spin' : ''}`}
/>
</button>
</div>
<div className="text-dark-100 font-medium">
{(trafficData?.traffic_used_gb ?? subscription.traffic_used_gb).toFixed(1)} / {subscription.traffic_limit_gb || ''} GB
<div className="font-medium text-dark-100">
{(trafficData?.traffic_used_gb ?? 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 className="mb-1 text-sm text-dark-500">{t('subscription.devices')}</div>
<div className="font-medium text-dark-100">{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">
<div className="mb-1 text-sm text-dark-500">{t('subscription.timeLeft')}</div>
<div className="font-medium text-dark-100">
{subscription.days_left > 0
? `${subscription.days_left} ${t('subscription.days')}`
: `${subscription.hours_left}${t('subscription.hours')} ${subscription.minutes_left}${t('subscription.minutes')}`
}
: `${subscription.hours_left}${t('subscription.hours')} ${subscription.minutes_left}${t('subscription.minutes')}`}
</div>
</div>
</div>
@@ -329,29 +353,36 @@ export default function Dashboard() {
{/* Traffic Progress */}
{subscription.traffic_limit_gb > 0 && (
<div className="mt-6">
<div className="flex justify-between text-sm mb-2">
<div className="mb-2 flex justify-between text-sm">
<span className="text-dark-400">{t('subscription.trafficUsed')}</span>
<span className="text-dark-300">
{(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent).toFixed(1)}%
{(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent).toFixed(
1,
)}
%
</span>
</div>
<div className="progress-bar">
<div
className={`progress-fill ${getTrafficColor(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent)}`}
style={{ width: `${Math.min(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent, 100)}%` }}
style={{
width: `${Math.min(trafficData?.traffic_used_percent ?? 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">
<div
className={`mt-6 grid gap-3 ${subscription.subscription_url ? 'grid-cols-2' : 'grid-cols-1'}`}
>
<Link to="/subscription" className="btn-primary py-2.5 text-center text-sm">
{t('dashboard.viewSubscription')}
</Link>
{subscription.subscription_url && (
<button
onClick={() => setShowConnectionModal(true)}
className="btn-secondary text-sm py-2.5"
className="btn-secondary py-2.5 text-sm"
data-onboarding="connect-devices"
>
{t('subscription.getConfig')}
@@ -365,23 +396,27 @@ export default function Dashboard() {
<div className="bento-grid">
{/* Balance */}
<Link to="/balance" className="bento-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">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm text-dark-400">{t('balance.currentBalance')}</span>
<span className="text-dark-600 transition-colors group-hover:text-accent-400">
<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>
<span className="ml-1 text-lg text-dark-400">{currencySymbol}</span>
</div>
</Link>
{/* Subscription */}
<Link to="/subscription" className="bento-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">
<Link
to="/subscription"
className="bento-card-hover group"
data-onboarding="subscription-status"
>
<div className="mb-3 flex items-center justify-between">
<span className="text-sm text-dark-400">{t('subscription.title')}</span>
<span className="text-dark-600 transition-colors group-hover:text-accent-400">
<ArrowRightIcon />
</span>
</div>
@@ -392,17 +427,17 @@ export default function Dashboard() {
{subscription.days_left > 0 ? (
<>
{subscription.days_left}
<span className="text-lg ml-1 text-dark-400">{t('subscription.days')}</span>
<span className="ml-1 text-lg 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>
<span className="ml-1 text-lg 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="ml-1 text-lg text-dark-400">{t('subscription.minutes')}</span>
</>
) : (
<span className="text-error-400">{t('subscription.expired')}</span>
@@ -415,9 +450,9 @@ export default function Dashboard() {
{/* Referrals */}
<Link to="/referral" className="bento-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">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm text-dark-400">{t('referral.stats.totalReferrals')}</span>
<span className="text-dark-600 transition-colors group-hover:text-accent-400">
<ArrowRightIcon />
</span>
</div>
@@ -430,9 +465,9 @@ export default function Dashboard() {
{/* Earnings */}
<Link to="/referral" className="bento-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">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm text-dark-400">{t('referral.stats.totalEarnings')}</span>
<span className="text-dark-600 transition-colors group-hover:text-accent-400">
<ArrowRightIcon />
</span>
</div>
@@ -450,56 +485,75 @@ export default function Dashboard() {
{hasNoSubscription && !trialLoading && trialInfo?.is_available && (
<div className="bento-card-glow 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">
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-accent-500/20">
<SparklesIcon />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-dark-100 mb-2">
<h3 className="mb-2 text-lg font-semibold text-dark-100">
{trialInfo.requires_payment
? t('subscription.trial.titlePaid', 'Trial Subscription')
: t('subscription.trial.title', 'Free Trial')}
</h3>
<p className="text-dark-400 text-sm mb-4">
<p className="mb-4 text-sm text-dark-400">
{t('subscription.trial.description', 'Try our VPN service for free!')}
</p>
<div className="flex gap-6 mb-6">
<div className="mb-6 flex gap-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 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-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 className="text-xs text-dark-500">
{t('subscription.trial.devices', 'devices')}
</div>
</div>
</div>
{trialInfo.requires_payment && trialInfo.price_rubles > 0 && (
<div className="bg-dark-800/50 rounded-xl p-4 mb-4 space-y-2">
<div className="flex justify-between items-center">
<span className="text-dark-400">{t('subscription.trial.price', 'Activation price')}:</span>
<span className="text-lg font-semibold text-accent-400">{trialInfo.price_rubles.toFixed(2)} {currencySymbol}</span>
<div className="mb-4 space-y-2 rounded-xl bg-dark-800/50 p-4">
<div className="flex items-center justify-between">
<span className="text-dark-400">
{t('subscription.trial.price', 'Activation price')}:
</span>
<span className="text-lg font-semibold text-accent-400">
{trialInfo.price_rubles.toFixed(2)} {currencySymbol}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-dark-400">{t('balance.currentBalance', 'Your balance')}:</span>
<span className={`text-lg font-semibold ${(balanceData?.balance_kopeks || 0) >= trialInfo.price_kopeks ? 'text-success-400' : 'text-warning-400'}`}>
<div className="flex items-center justify-between">
<span className="text-dark-400">
{t('balance.currentBalance', 'Your balance')}:
</span>
<span
className={`text-lg font-semibold ${(balanceData?.balance_kopeks || 0) >= trialInfo.price_kopeks ? 'text-success-400' : 'text-warning-400'}`}
>
{formatAmount(balanceData?.balance_rubles || 0)} {currencySymbol}
</span>
</div>
{(balanceData?.balance_kopeks || 0) < trialInfo.price_kopeks && (
<div className="text-xs text-warning-400 pt-1">
{t('subscription.trial.insufficientBalance', 'Top up your balance to activate')}
<div className="pt-1 text-xs text-warning-400">
{t(
'subscription.trial.insufficientBalance',
'Top up your balance to activate',
)}
</div>
)}
</div>
)}
{trialError && (
<div className="bg-error-500/10 border border-error-500/30 text-error-400 text-sm p-3 rounded-xl mb-4">
<div className="mb-4 rounded-xl border border-error-500/30 bg-error-500/10 p-3 text-sm text-error-400">
{trialError}
</div>
)}
@@ -516,7 +570,7 @@ export default function Dashboard() {
: t('subscription.trial.payAndActivate', 'Pay from Balance & Activate')}
</button>
) : (
<Link to="/balance" className="btn-primary w-full text-center block">
<Link to="/balance" className="btn-primary block w-full text-center">
{t('subscription.trial.topUpToActivate', 'Top Up Balance')}
</Link>
)
@@ -541,23 +595,16 @@ export default function Dashboard() {
{/* Fortune Wheel Banner */}
{wheelConfig?.is_enabled && (
<Link
to="/wheel"
className="group bento-card-hover flex items-center justify-between"
>
<Link to="/wheel" className="bento-card-hover group 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 className="min-w-0 flex-1">
<h3 className="text-base font-semibold text-dark-100">{t('wheel.banner.title')}</h3>
<p className="text-sm text-dark-400">{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">
<div className="flex-shrink-0 text-dark-500 transition-all duration-300 group-hover:translate-x-1 group-hover:text-accent-400">
<ChevronRightIcon />
</div>
</Link>
@@ -565,18 +612,25 @@ export default function Dashboard() {
{/* Quick Actions */}
<div className="bento-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">
<h3 className="mb-4 text-lg font-semibold text-dark-100">{t('dashboard.quickActions')}</h3>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Link to="/balance" className="btn-secondary justify-center py-2.5 text-center text-sm">
{t('dashboard.topUpBalance')}
</Link>
<Link to="/subscription" state={{ scrollToExtend: true }} className="btn-secondary justify-center text-center text-sm py-2.5">
<Link
to="/subscription"
state={{ scrollToExtend: true }}
className="btn-secondary justify-center py-2.5 text-center text-sm"
>
{t('subscription.renew')}
</Link>
<Link to="/referral" className="btn-secondary justify-center text-center text-sm py-2.5">
<Link to="/referral" className="btn-secondary justify-center py-2.5 text-center text-sm">
{t('dashboard.inviteFriends')}
</Link>
<Link to="/support" className="btn-secondary justify-center text-center text-sm py-2.5 flex items-center gap-2">
<Link
to="/support"
className="btn-secondary flex items-center justify-center gap-2 py-2.5 text-center text-sm"
>
<SupportLottieIcon />
<span>{t('dashboard.getSupport')}</span>
</Link>
@@ -584,9 +638,7 @@ export default function Dashboard() {
</div>
{/* Connection Modal */}
{showConnectionModal && (
<ConnectionModal onClose={() => setShowConnectionModal(false)} />
)}
{showConnectionModal && <ConnectionModal onClose={() => setShowConnectionModal(false)} />}
{/* Onboarding Tutorial */}
{showOnboarding && (
@@ -597,5 +649,5 @@ export default function Dashboard() {
/>
)}
</div>
)
);
}

View File

@@ -1,10 +1,10 @@
import { useEffect, useState, useCallback, useRef } from 'react'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query'
import { brandingApi } from '../api/branding'
import { useEffect, useState, useCallback, useRef } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { brandingApi } from '../api/branding';
type Status = 'countdown' | 'fallback' | 'error'
type Status = 'countdown' | 'fallback' | 'error';
// App schemes configuration - same as miniapp
const appSchemes = [
@@ -22,55 +22,56 @@ const appSchemes = [
{ scheme: 'loon://', icon: 'L', name: 'Loon' },
{ scheme: 'nekobox://', icon: 'N', name: 'NekoBox' },
{ scheme: 'v2box://', icon: 'V', name: 'V2Box' },
]
];
const COUNTDOWN_SECONDS = 5
const COUNTDOWN_SECONDS = 5;
// Validate deep link to prevent javascript: and other dangerous schemes
const isValidDeepLink = (url: string): boolean => {
if (!url) return false
const lowerUrl = url.toLowerCase().trim()
if (!url) return false;
const lowerUrl = url.toLowerCase().trim();
// Block dangerous schemes
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']
if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) {
return false
// eslint-disable-next-line no-script-url -- listing dangerous schemes to block them
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:'];
if (dangerousSchemes.some((scheme) => lowerUrl.startsWith(scheme))) {
return false;
}
// Only allow known app schemes
return appSchemes.some(app => lowerUrl.startsWith(app.scheme))
}
return appSchemes.some((app) => lowerUrl.startsWith(app.scheme));
};
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)
const fallbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
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);
const fallbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 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
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') || ''
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'
? 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 = {
@@ -111,120 +112,122 @@ export default function DeepLinkRedirect() {
step2: 'Откройте приложение',
step3: 'Найдите "+" или "Добавить подписку"',
step4: 'Выберите "Из буфера" или "Вставить ссылку"',
}
}
},
};
const lang = i18n.language?.startsWith('ru') ? 'ru' : 'en'
const txt = texts[lang]
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 || !isValidDeepLink(deepLink)) return
window.location.href = deepLink
}, [deepLink])
if (!deepLink || !isValidDeepLink(deepLink)) return;
window.location.href = deepLink;
}, [deepLink]);
// Countdown timer effect
useEffect(() => {
if (!deepLink || !isValidDeepLink(deepLink)) {
setStatus('error')
return
setStatus('error');
return;
}
if (status !== 'countdown') return
if (status !== 'countdown') return;
const timer = setInterval(() => {
setCountdown(prev => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(timer)
openDeepLink()
clearInterval(timer);
openDeepLink();
// Show fallback after a delay - store ref for cleanup
fallbackTimeoutRef.current = setTimeout(() => setStatus('fallback'), 2000)
return 0
fallbackTimeoutRef.current = setTimeout(() => setStatus('fallback'), 2000);
return 0;
}
return prev - 1
})
}, 1000)
return prev - 1;
});
}, 1000);
return () => {
clearInterval(timer)
clearInterval(timer);
// Cleanup fallback timeout on unmount
if (fallbackTimeoutRef.current) {
clearTimeout(fallbackTimeoutRef.current)
fallbackTimeoutRef.current = null
clearTimeout(fallbackTimeoutRef.current);
fallbackTimeoutRef.current = null;
}
}
}, [deepLink, status, openDeepLink])
};
}, [deepLink, status, openDeepLink]);
const handleCopyLink = async () => {
const linkToCopy = subscriptionUrl || deepLink
const linkToCopy = subscriptionUrl || deepLink;
// Clear previous timeout to prevent stacking
if (copiedTimeoutRef.current) {
clearTimeout(copiedTimeoutRef.current)
clearTimeout(copiedTimeoutRef.current);
}
try {
await navigator.clipboard.writeText(linkToCopy)
setCopied(true)
copiedTimeoutRef.current = setTimeout(() => setCopied(false), 2000)
await navigator.clipboard.writeText(linkToCopy);
setCopied(true);
copiedTimeoutRef.current = 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)
copiedTimeoutRef.current = setTimeout(() => setCopied(false), 2000)
const textarea = document.createElement('textarea');
textarea.value = linkToCopy;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
setCopied(true);
copiedTimeoutRef.current = setTimeout(() => setCopied(false), 2000);
}
}
};
// Cleanup copied timeout on unmount
useEffect(() => {
return () => {
if (copiedTimeoutRef.current) {
clearTimeout(copiedTimeoutRef.current)
clearTimeout(copiedTimeoutRef.current);
}
}
}, [])
};
}, []);
// Progress percentage
const progress = ((COUNTDOWN_SECONDS - countdown) / COUNTDOWN_SECONDS) * 100
const progress = ((COUNTDOWN_SECONDS - countdown) / COUNTDOWN_SECONDS) * 100;
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="flex min-h-screen 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">
<div className="relative w-full max-w-sm text-center">
{/* 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">
<div className="mx-auto mb-6 flex h-20 w-20 animate-pulse items-center justify-center overflow-hidden rounded-2xl bg-gradient-to-br from-accent-400 to-accent-600 shadow-lg shadow-accent-500/30">
{branding?.has_custom_logo && logoUrl ? (
<img src={logoUrl} alt={projectName || 'Logo'} className="w-full h-full object-cover" />
<img src={logoUrl} alt={projectName || 'Logo'} className="h-full w-full object-cover" />
) : (
<span className="text-white font-bold text-3xl">{logoLetter}</span>
<span className="text-3xl font-bold text-white">{logoLetter}</span>
)}
</div>
<h1 className="text-2xl font-bold text-dark-50 mb-1">{projectName || 'VPN'}</h1>
<h1 className="mb-1 text-2xl font-bold text-dark-50">{projectName || 'VPN'}</h1>
{status !== 'error' && (
<p className="text-dark-400 mb-6">{txt.connecting} {appName}...</p>
<p className="mb-6 text-dark-400">
{txt.connecting} {appName}...
</p>
)}
{/* Countdown State */}
{status === 'countdown' && (
<div className="card !bg-dark-800/80 backdrop-blur-sm p-6">
<div className="card !bg-dark-800/80 p-6 backdrop-blur-sm">
{/* App icon */}
<div className="w-16 h-16 rounded-2xl bg-accent-500/20 flex items-center justify-center mx-auto mb-4">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-accent-500/20">
<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" />
<div className="border-3 mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-dark-700 border-t-accent-500" />
{/* Timer */}
<div className="mb-4">
<p className="text-sm text-dark-500 mb-2">{txt.redirecting}</p>
<p className="mb-2 text-sm text-dark-500">{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>
@@ -232,22 +235,32 @@ export default function DeepLinkRedirect() {
</div>
{/* Progress bar */}
<div className="w-full h-1.5 bg-dark-700 rounded-full overflow-hidden mb-4">
<div className="mb-4 h-1.5 w-full overflow-hidden rounded-full bg-dark-700">
<div
className="h-full bg-gradient-to-r from-accent-400 to-accent-600 rounded-full transition-all duration-1000 ease-linear"
className="h-full rounded-full bg-gradient-to-r from-accent-400 to-accent-600 transition-all duration-1000 ease-linear"
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-sm text-dark-500 mb-4">{txt.manual}</p>
<p className="mb-4 text-sm text-dark-500">{txt.manual}</p>
{/* Open now button */}
<button
onClick={openDeepLink}
className="btn-primary w-full py-3 flex items-center justify-center gap-2"
className="btn-primary flex w-full items-center justify-center gap-2 py-3"
>
<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
className="h-5 w-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>
@@ -256,9 +269,9 @@ export default function DeepLinkRedirect() {
{/* Fallback State - App didn't open */}
{status === 'fallback' && (
<div className="card !bg-dark-800/80 backdrop-blur-sm p-6">
<div className="card !bg-dark-800/80 p-6 backdrop-blur-sm">
{/* App icon */}
<div className="w-16 h-16 rounded-2xl bg-accent-500/20 flex items-center justify-center mx-auto mb-4">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-accent-500/20">
<span className="text-2xl font-bold text-accent-400">{appIcon}</span>
</div>
@@ -266,19 +279,39 @@ export default function DeepLinkRedirect() {
{/* Copy subscription link */}
<button
onClick={handleCopyLink}
className="btn-primary w-full py-3 flex items-center justify-center gap-2"
className="btn-primary flex w-full items-center justify-center gap-2 py-3"
>
{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
className="h-5 w-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
className="h-5 w-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}
</>
@@ -288,10 +321,20 @@ export default function DeepLinkRedirect() {
{/* Try again button */}
<button
onClick={openDeepLink}
className="btn-secondary w-full flex items-center justify-center gap-2"
className="btn-secondary flex w-full 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
className="h-5 w-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>
@@ -299,18 +342,20 @@ export default function DeepLinkRedirect() {
{/* Back to cabinet */}
<button
onClick={() => navigate('/subscription')}
className="w-full text-sm text-dark-500 hover:text-dark-300 transition-colors py-2"
className="w-full py-2 text-sm text-dark-500 transition-colors hover:text-dark-300"
>
{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">
<div className="mt-6 rounded-xl border border-dark-700 bg-dark-900/50 p-4 text-left">
<h3 className="mb-2 text-sm font-medium text-dark-200">{txt.howToAdd}</h3>
<ol className="list-inside list-decimal space-y-1.5 text-xs text-dark-400">
<li>{txt.step1}</li>
<li>{txt.step2} {appName}</li>
<li>
{txt.step2} {appName}
</li>
<li>{txt.step3}</li>
<li>{txt.step4}</li>
</ol>
@@ -320,18 +365,25 @@ export default function DeepLinkRedirect() {
{/* 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" />
<div className="card !bg-dark-800/80 p-6 backdrop-blur-sm">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-error-500/20">
<svg
className="h-8 w-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"
>
<p className="mb-2 font-medium text-dark-200">{txt.errorTitle}</p>
<p className="mb-6 text-sm text-dark-400">{txt.errorDesc}</p>
<button onClick={() => navigate('/subscription')} className="btn-primary w-full">
{txt.goToSubscription}
</button>
</div>
@@ -339,12 +391,22 @@ export default function DeepLinkRedirect() {
{/* 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
className="h-4 w-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>
)
);
}

View File

@@ -1,43 +1,63 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import DOMPurify from 'dompurify'
import { infoApi, FaqPage } from '../api/info'
import { promoApi, LoyaltyTierInfo } from '../api/promo'
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import DOMPurify from 'dompurify';
import { infoApi, FaqPage } from '../api/info';
import { promoApi, LoyaltyTierInfo } from '../api/promo';
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 className="h-6 w-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 className="h-5 w-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 className="h-5 w-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 className="h-5 w-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 StarIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<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 className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<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 ChevronIcon = ({ expanded }: { expanded: boolean }) => (
<svg
className={`w-5 h-5 transition-transform ${expanded ? 'rotate-180' : ''}`}
className={`h-5 w-5 transition-transform ${expanded ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -45,69 +65,90 @@ const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
>
<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' | 'loyalty'
type TabType = 'faq' | 'rules' | 'privacy' | 'offer' | 'loyalty';
// Sanitize HTML content to prevent XSS
const sanitizeHtml = (html: string): string => {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p', 'br', 'b', 'i', 'u', 'strong', 'em', 'a', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'pre', 'span', 'div'],
ALLOWED_TAGS: [
'p',
'br',
'b',
'i',
'u',
'strong',
'em',
'a',
'ul',
'ol',
'li',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'blockquote',
'code',
'pre',
'span',
'div',
],
ALLOWED_ATTR: ['href', 'target', 'rel', 'class'],
ALLOW_DATA_ATTR: false,
})
}
});
};
// Convert plain text to HTML with proper formatting
const formatContent = (content: string): string => {
if (!content) return ''
if (!content) return '';
// Check if content already has HTML tags
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(content)
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(content);
if (hasHtmlTags) {
return sanitizeHtml(content)
return sanitizeHtml(content);
}
// Convert plain text to formatted HTML
const result = content
.split(/\n\n+/) // Split by double newlines (paragraphs)
.map(paragraph => {
.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}>`
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 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>`
.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/>')
const formattedParagraph = paragraph.split('\n').join('<br/>');
return `<p>${formattedParagraph}</p>`
return `<p>${formattedParagraph}</p>`;
})
.join('')
.join('');
return sanitizeHtml(result)
}
return sanitizeHtml(result);
};
export default function Info() {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState<TabType>('faq')
const [expandedFaq, setExpandedFaq] = useState<number | null>(null)
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'],
@@ -115,7 +156,7 @@ export default function Info() {
enabled: activeTab === 'faq',
staleTime: 0,
refetchOnMount: 'always',
})
});
const { data: rules, isLoading: rulesLoading } = useQuery({
queryKey: ['rules'],
@@ -123,7 +164,7 @@ export default function Info() {
enabled: activeTab === 'rules',
staleTime: 0,
refetchOnMount: 'always',
})
});
const { data: privacy, isLoading: privacyLoading } = useQuery({
queryKey: ['privacy-policy'],
@@ -131,7 +172,7 @@ export default function Info() {
enabled: activeTab === 'privacy',
staleTime: 0,
refetchOnMount: 'always',
})
});
const { data: offer, isLoading: offerLoading } = useQuery({
queryKey: ['public-offer'],
@@ -139,7 +180,7 @@ export default function Info() {
enabled: activeTab === 'offer',
staleTime: 0,
refetchOnMount: 'always',
})
});
const { data: loyaltyData, isLoading: loyaltyLoading } = useQuery({
queryKey: ['loyalty-tiers'],
@@ -147,7 +188,7 @@ export default function Info() {
enabled: activeTab === 'loyalty',
staleTime: 0,
refetchOnMount: 'always',
})
});
const tabs = [
{ id: 'faq' as TabType, label: t('info.faq'), icon: QuestionIcon },
@@ -155,154 +196,134 @@ export default function Info() {
{ id: 'privacy' as TabType, label: t('info.privacy'), icon: ShieldIcon },
{ id: 'offer' as TabType, label: t('info.offer'), icon: DocumentIcon },
{ id: 'loyalty' as TabType, label: t('info.loyalty'), icon: StarIcon },
]
];
const toggleFaq = (id: number) => {
setExpandedFaq(expandedFaq === id ? null : id)
}
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 className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
)
);
}
if (!faqPages || faqPages.length === 0) {
return (
<div className="text-center py-8 text-dark-400">
{t('info.noFaq')}
</div>
)
return <div className="py-8 text-center text-dark-400">{t('info.noFaq')}</div>;
}
return (
<div className="space-y-2">
{faqPages.map((faq: FaqPage) => (
<div key={faq.id} className="bento-card p-0 overflow-hidden">
<div key={faq.id} className="bento-card overflow-hidden p-0">
<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"
className="flex w-full items-center justify-between px-4 py-3 text-left transition-colors hover:bg-dark-800/50"
>
<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 className="prose prose-invert max-w-none px-4 pb-4 text-dark-300">
<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 className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
)
);
}
if (!rules?.content) {
return (
<div className="text-center py-8 text-dark-400">
{t('info.noContent')}
</div>
)
return <div className="py-8 text-center text-dark-400">{t('info.noContent')}</div>;
}
return (
<div className="bento-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">
<p className="mt-6 border-t border-dark-700 pt-4 text-sm text-dark-400">
{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 className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
)
);
}
if (!privacy?.content) {
return (
<div className="text-center py-8 text-dark-400">
{t('info.noContent')}
</div>
)
return <div className="py-8 text-center text-dark-400">{t('info.noContent')}</div>;
}
return (
<div className="bento-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">
<p className="mt-6 border-t border-dark-700 pt-4 text-sm text-dark-400">
{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 className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
)
);
}
if (!offer?.content) {
return (
<div className="text-center py-8 text-dark-400">
{t('info.noContent')}
</div>
)
return <div className="py-8 text-center text-dark-400">{t('info.noContent')}</div>;
}
return (
<div className="bento-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">
<p className="mt-6 border-t border-dark-700 pt-4 text-sm text-dark-400">
{t('info.updatedAt')}: {new Date(offer.updated_at).toLocaleDateString()}
</p>
)}
</div>
)
);
}
if (activeTab === 'loyalty') {
if (loyaltyLoading) {
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 className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
)
);
}
if (!loyaltyData || loyaltyData.tiers.length === 0) {
return (
<div className="text-center py-8 text-dark-400">
{t('info.noLoyaltyTiers')}
</div>
)
return <div className="py-8 text-center text-dark-400">{t('info.noLoyaltyTiers')}</div>;
}
const formatCurrency = (amount: number) => {
@@ -311,30 +332,30 @@ export default function Info() {
currency: 'RUB',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount)
}
}).format(amount);
};
const getStatusBadge = (tier: LoyaltyTierInfo) => {
if (tier.is_current) {
return (
<span className="px-2 py-1 text-xs font-medium bg-accent-500/20 text-accent-400 rounded-full">
<span className="rounded-full bg-accent-500/20 px-2 py-1 text-xs font-medium text-accent-400">
{t('info.statusCurrent')}
</span>
)
);
}
if (tier.is_achieved) {
return (
<span className="px-2 py-1 text-xs font-medium bg-success-500/20 text-success-400 rounded-full">
<span className="rounded-full bg-success-500/20 px-2 py-1 text-xs font-medium text-success-400">
{t('info.statusAchieved')}
</span>
)
);
}
return (
<span className="px-2 py-1 text-xs font-medium bg-dark-600 text-dark-400 rounded-full">
<span className="rounded-full bg-dark-600 px-2 py-1 text-xs font-medium text-dark-400">
{t('info.statusLocked')}
</span>
)
}
);
};
const hasAnyDiscount = (tier: LoyaltyTierInfo) => {
return (
@@ -342,24 +363,24 @@ export default function Info() {
tier.traffic_discount_percent > 0 ||
tier.device_discount_percent > 0 ||
Object.keys(tier.period_discounts).length > 0
)
}
);
};
return (
<div className="space-y-6">
{/* Progress Card */}
<div className="bento-card p-5">
<h3 className="text-lg font-semibold text-dark-50 mb-4">{t('info.yourProgress')}</h3>
<h3 className="mb-4 text-lg font-semibold text-dark-50">{t('info.yourProgress')}</h3>
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="bg-dark-800/50 rounded-xl p-3">
<div className="text-xs text-dark-400 mb-1">{t('info.totalSpent')}</div>
<div className="mb-4 grid grid-cols-2 gap-4">
<div className="rounded-xl bg-dark-800/50 p-3">
<div className="mb-1 text-xs text-dark-400">{t('info.totalSpent')}</div>
<div className="text-lg font-bold text-dark-50">
{formatCurrency(loyaltyData.current_spent_rubles)}
</div>
</div>
<div className="bg-dark-800/50 rounded-xl p-3">
<div className="text-xs text-dark-400 mb-1">{t('info.currentStatus')}</div>
<div className="rounded-xl bg-dark-800/50 p-3">
<div className="mb-1 text-xs text-dark-400">{t('info.currentStatus')}</div>
<div className="text-lg font-bold text-accent-400">
{loyaltyData.current_tier_name || '-'}
</div>
@@ -369,22 +390,29 @@ export default function Info() {
{/* Progress bar to next tier */}
{loyaltyData.next_tier_name && loyaltyData.next_tier_threshold_rubles ? (
<div>
<div className="flex justify-between text-xs text-dark-400 mb-2">
<span>{t('info.nextStatus')}: {loyaltyData.next_tier_name}</span>
<span>{t('info.toNextStatus')}: {formatCurrency(loyaltyData.next_tier_threshold_rubles - loyaltyData.current_spent_rubles)}</span>
<div className="mb-2 flex justify-between text-xs text-dark-400">
<span>
{t('info.nextStatus')}: {loyaltyData.next_tier_name}
</span>
<span>
{t('info.toNextStatus')}:{' '}
{formatCurrency(
loyaltyData.next_tier_threshold_rubles - loyaltyData.current_spent_rubles,
)}
</span>
</div>
<div className="h-3 bg-dark-700 rounded-full overflow-hidden">
<div className="h-3 overflow-hidden rounded-full bg-dark-700">
<div
className="h-full bg-gradient-to-r from-accent-500 to-accent-400 rounded-full transition-all duration-500"
className="h-full rounded-full bg-gradient-to-r from-accent-500 to-accent-400 transition-all duration-500"
style={{ width: `${Math.min(100, loyaltyData.progress_percent)}%` }}
/>
</div>
<div className="text-right text-xs text-dark-400 mt-1">
<div className="mt-1 text-right text-xs text-dark-400">
{loyaltyData.progress_percent.toFixed(1)}%
</div>
</div>
) : (
<div className="text-center py-2 text-success-400 font-medium">
<div className="py-2 text-center font-medium text-success-400">
{t('info.allStatusesAchieved')}
</div>
)}
@@ -397,21 +425,21 @@ export default function Info() {
key={tier.id}
className={`bento-card p-4 transition-all ${
tier.is_current
? 'ring-2 ring-accent-500/50 bg-accent-500/5'
? 'bg-accent-500/5 ring-2 ring-accent-500/50'
: tier.is_achieved
? 'bg-success-500/5'
: 'opacity-70'
? 'bg-success-500/5'
: 'opacity-70'
}`}
>
<div className="flex items-center justify-between mb-3">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className={`w-10 h-10 rounded-xl flex items-center justify-center ${
className={`flex h-10 w-10 items-center justify-center rounded-xl ${
tier.is_current
? 'bg-accent-500/20 text-accent-400'
: tier.is_achieved
? 'bg-success-500/20 text-success-400'
: 'bg-dark-700 text-dark-400'
? 'bg-success-500/20 text-success-400'
: 'bg-dark-700 text-dark-400'
}`}
>
<StarIcon />
@@ -428,49 +456,52 @@ export default function Info() {
{/* Discounts */}
{hasAnyDiscount(tier) ? (
<div className="bg-dark-800/50 rounded-xl p-3">
<div className="text-xs text-dark-400 mb-2">{t('info.discounts')}:</div>
<div className="rounded-xl bg-dark-800/50 p-3">
<div className="mb-2 text-xs text-dark-400">{t('info.discounts')}:</div>
<div className="flex flex-wrap gap-2">
{tier.server_discount_percent > 0 && (
<span className="px-2 py-1 text-xs bg-dark-700 rounded-lg text-dark-200">
<span className="rounded-lg bg-dark-700 px-2 py-1 text-xs text-dark-200">
{t('info.serverDiscount')}: -{tier.server_discount_percent}%
</span>
)}
{tier.traffic_discount_percent > 0 && (
<span className="px-2 py-1 text-xs bg-dark-700 rounded-lg text-dark-200">
<span className="rounded-lg bg-dark-700 px-2 py-1 text-xs text-dark-200">
{t('info.trafficDiscount')}: -{tier.traffic_discount_percent}%
</span>
)}
{tier.device_discount_percent > 0 && (
<span className="px-2 py-1 text-xs bg-dark-700 rounded-lg text-dark-200">
<span className="rounded-lg bg-dark-700 px-2 py-1 text-xs text-dark-200">
{t('info.deviceDiscount')}: -{tier.device_discount_percent}%
</span>
)}
{Object.entries(tier.period_discounts).map(([days, percent]) => (
<span key={days} className="px-2 py-1 text-xs bg-dark-700 rounded-lg text-dark-200">
<span
key={days}
className="rounded-lg bg-dark-700 px-2 py-1 text-xs text-dark-200"
>
{t('info.periodDiscount', { days })}: -{percent}%
</span>
))}
</div>
</div>
) : (
<div className="text-xs text-dark-500 italic">{t('info.noDiscounts')}</div>
<div className="text-xs italic text-dark-500">{t('info.noDiscounts')}</div>
)}
</div>
))}
</div>
</div>
)
);
}
return null
}
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>
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('info.title')}</h1>
</div>
{/* Tabs */}
@@ -479,7 +510,7 @@ export default function Info() {
<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 ${
className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
activeTab === tab.id
? 'bg-accent-500 text-white'
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
@@ -494,5 +525,5 @@ export default function Info() {
{/* Content */}
{renderContent()}
</div>
)
);
}

View File

@@ -1,244 +1,259 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query'
import { useAuthStore } from '../store/auth'
import { authApi } from '../api/auth'
import { brandingApi, getCachedBranding, setCachedBranding, preloadLogo, isLogoPreloaded, type BrandingInfo, type EmailAuthEnabled } from '../api/branding'
import { getAndClearReturnUrl } from '../utils/token'
import LanguageSwitcher from '../components/LanguageSwitcher'
import TelegramLoginButton from '../components/TelegramLoginButton'
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '../store/auth';
import { authApi } from '../api/auth';
import {
brandingApi,
getCachedBranding,
setCachedBranding,
preloadLogo,
isLogoPreloaded,
type BrandingInfo,
type EmailAuthEnabled,
} from '../api/branding';
import { getAndClearReturnUrl } from '../utils/token';
import LanguageSwitcher from '../components/LanguageSwitcher';
import TelegramLoginButton from '../components/TelegramLoginButton';
export default function Login() {
const { t } = useTranslation()
const navigate = useNavigate()
const location = useLocation()
const [searchParams] = useSearchParams()
const { isAuthenticated, loginWithTelegram, loginWithEmail, registerWithEmail } = useAuthStore()
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const { isAuthenticated, loginWithTelegram, loginWithEmail, registerWithEmail } = useAuthStore();
// Extract referral code from URL
const referralCode = searchParams.get('ref') || ''
const referralCode = searchParams.get('ref') || '';
const [activeTab, setActiveTab] = useState<'telegram' | 'email'>(() =>
referralCode ? 'email' : 'telegram'
)
referralCode ? 'email' : 'telegram',
);
const [authMode, setAuthMode] = useState<'login' | 'register'>(() =>
referralCode ? 'register' : 'login'
)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [firstName, setFirstName] = useState('')
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isTelegramWebApp, setIsTelegramWebApp] = useState(false)
const [logoLoaded, setLogoLoaded] = useState(() => isLogoPreloaded())
const [registeredEmail, setRegisteredEmail] = useState<string | null>(null)
const [showForgotPassword, setShowForgotPassword] = useState(false)
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('')
const [forgotPasswordSent, setForgotPasswordSent] = useState(false)
const [forgotPasswordLoading, setForgotPasswordLoading] = useState(false)
const [forgotPasswordError, setForgotPasswordError] = useState('')
referralCode ? 'register' : 'login',
);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [firstName, setFirstName] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isTelegramWebApp, setIsTelegramWebApp] = useState(false);
const [logoLoaded, setLogoLoaded] = useState(() => isLogoPreloaded());
const [registeredEmail, setRegisteredEmail] = useState<string | null>(null);
const [showForgotPassword, setShowForgotPassword] = useState(false);
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('');
const [forgotPasswordSent, setForgotPasswordSent] = useState(false);
const [forgotPasswordLoading, setForgotPasswordLoading] = useState(false);
const [forgotPasswordError, setForgotPasswordError] = useState('');
// Получаем URL для возврата после авторизации
const getReturnUrl = useCallback(() => {
// Сначала проверяем state от React Router
const stateFrom = (location.state as { from?: string })?.from
const stateFrom = (location.state as { from?: string })?.from;
if (stateFrom && stateFrom !== '/login') {
return stateFrom
return stateFrom;
}
// Затем проверяем сохранённый URL в sessionStorage (от safeRedirectToLogin)
const savedUrl = getAndClearReturnUrl()
const savedUrl = getAndClearReturnUrl();
if (savedUrl && savedUrl !== '/login') {
return savedUrl
return savedUrl;
}
// По умолчанию на главную
return '/'
}, [location.state])
return '/';
}, [location.state]);
// Fetch branding with unified cache
const cachedBranding = useMemo(() => getCachedBranding(), [])
const cachedBranding = useMemo(() => getCachedBranding(), []);
const { data: branding } = useQuery<BrandingInfo>({
queryKey: ['branding'],
queryFn: async () => {
const data = await brandingApi.getBranding()
setCachedBranding(data)
preloadLogo(data)
return data
const data = await brandingApi.getBranding();
setCachedBranding(data);
preloadLogo(data);
return data;
},
staleTime: 60000,
initialData: cachedBranding ?? undefined,
})
});
// Check if email auth is enabled
const { data: emailAuthConfig } = useQuery<EmailAuthEnabled>({
queryKey: ['email-auth-enabled'],
queryFn: brandingApi.getEmailAuthEnabled,
staleTime: 60000,
})
const isEmailAuthEnabled = emailAuthConfig?.enabled ?? true
});
const isEmailAuthEnabled = emailAuthConfig?.enabled ?? true;
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || ''
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
// If email auth is disabled but user came with ref param, redirect to bot
useEffect(() => {
if (referralCode && emailAuthConfig?.enabled === false && botUsername) {
window.location.href = `https://t.me/${botUsername}?start=ref_${referralCode}`
window.location.href = `https://t.me/${botUsername}?start=ref_${referralCode}`;
}
}, [referralCode, emailAuthConfig, botUsername])
}, [referralCode, emailAuthConfig, botUsername]);
// If email auth is disabled but we initially set to email tab, switch back to telegram
useEffect(() => {
if (!isEmailAuthEnabled && activeTab === 'email') {
setActiveTab('telegram')
setActiveTab('telegram');
}
}, [isEmailAuthEnabled, activeTab])
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
}, [isEmailAuthEnabled, activeTab]);
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])
document.title = appName || 'VPN';
}, [appName]);
useEffect(() => {
if (isAuthenticated) {
navigate(getReturnUrl(), { replace: true })
navigate(getReturnUrl(), { replace: true });
}
}, [isAuthenticated, navigate, getReturnUrl])
}, [isAuthenticated, navigate, getReturnUrl]);
// Try Telegram WebApp authentication on mount
useEffect(() => {
const tryTelegramAuth = async () => {
const tg = window.Telegram?.WebApp
const tg = window.Telegram?.WebApp;
if (tg?.initData) {
setIsTelegramWebApp(true)
tg.ready()
tg.expand()
setIsLoading(true)
setIsTelegramWebApp(true);
tg.ready();
tg.expand();
setIsLoading(true);
try {
await loginWithTelegram(tg.initData)
navigate(getReturnUrl(), { replace: true })
await loginWithTelegram(tg.initData);
navigate(getReturnUrl(), { replace: true });
} catch (err) {
// Log only status code to avoid leaking sensitive data
const status = (err as { response?: { status?: number } })?.response?.status
console.warn('Telegram auth failed with status:', status)
setError(t('auth.telegramRequired'))
const status = (err as { response?: { status?: number } })?.response?.status;
console.warn('Telegram auth failed with status:', status);
setError(t('auth.telegramRequired'));
} finally {
setIsLoading(false)
setIsLoading(false);
}
}
}
};
tryTelegramAuth()
}, [loginWithTelegram, navigate, t, getReturnUrl])
tryTelegramAuth();
}, [loginWithTelegram, navigate, t, getReturnUrl]);
const handleEmailSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
e.preventDefault();
setError('');
// Валидация email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!email.trim() || !emailRegex.test(email.trim())) {
setError(t('auth.invalidEmail', 'Please enter a valid email address'))
return
setError(t('auth.invalidEmail', 'Please enter a valid email address'));
return;
}
if (authMode === 'register') {
// Валидация для регистрации
if (password !== confirmPassword) {
setError(t('auth.passwordMismatch', 'Passwords do not match'))
return
setError(t('auth.passwordMismatch', 'Passwords do not match'));
return;
}
if (password.length < 8) {
setError(t('auth.passwordTooShort', 'Password must be at least 8 characters'))
return
setError(t('auth.passwordTooShort', 'Password must be at least 8 characters'));
return;
}
}
setIsLoading(true)
setIsLoading(true);
try {
if (authMode === 'login') {
await loginWithEmail(email, password)
navigate(getReturnUrl(), { replace: true })
await loginWithEmail(email, password);
navigate(getReturnUrl(), { replace: true });
} else {
const result = await registerWithEmail(email, password, firstName || undefined, referralCode || undefined)
const result = await registerWithEmail(
email,
password,
firstName || undefined,
referralCode || undefined,
);
// Show "check your email" screen
setRegisteredEmail(result.email)
setRegisteredEmail(result.email);
}
} catch (err: unknown) {
const error = err as { response?: { status?: number; data?: { detail?: string } } }
const status = error.response?.status
const detail = error.response?.data?.detail
const error = err as { response?: { status?: number; data?: { detail?: string } } };
const status = error.response?.status;
const detail = error.response?.data?.detail;
if (status === 400 && detail?.includes('already registered')) {
setError(t('auth.emailAlreadyRegistered', 'This email is already registered'))
setError(t('auth.emailAlreadyRegistered', 'This email is already registered'));
} else if (status === 401 || status === 403) {
if (detail?.includes('verify your email')) {
setError(t('auth.emailNotVerified', 'Please verify your email first'))
setError(t('auth.emailNotVerified', 'Please verify your email first'));
} else {
setError(t('auth.invalidCredentials', 'Invalid email or password'))
setError(t('auth.invalidCredentials', 'Invalid email or password'));
}
} else if (status === 429) {
setError(t('auth.tooManyAttempts', 'Too many attempts. Please try again later'))
setError(t('auth.tooManyAttempts', 'Too many attempts. Please try again later'));
} else {
setError(detail || t('common.error'))
setError(detail || t('common.error'));
}
} finally {
setIsLoading(false)
setIsLoading(false);
}
}
};
const handleForgotPassword = async (e: React.FormEvent) => {
e.preventDefault()
setForgotPasswordError('')
e.preventDefault();
setForgotPasswordError('');
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!forgotPasswordEmail.trim() || !emailRegex.test(forgotPasswordEmail.trim())) {
setForgotPasswordError(t('auth.invalidEmail', 'Please enter a valid email address'))
return
setForgotPasswordError(t('auth.invalidEmail', 'Please enter a valid email address'));
return;
}
setForgotPasswordLoading(true)
setForgotPasswordLoading(true);
try {
await authApi.forgotPassword(forgotPasswordEmail.trim())
setForgotPasswordSent(true)
await authApi.forgotPassword(forgotPasswordEmail.trim());
setForgotPasswordSent(true);
} catch (err: unknown) {
const error = err as { response?: { status?: number; data?: { detail?: string } } }
const detail = error.response?.data?.detail
setForgotPasswordError(detail || t('common.error'))
const error = err as { response?: { status?: number; data?: { detail?: string } } };
const detail = error.response?.data?.detail;
setForgotPasswordError(detail || t('common.error'));
} finally {
setForgotPasswordLoading(false)
setForgotPasswordLoading(false);
}
}
};
const closeForgotPasswordModal = () => {
setShowForgotPassword(false)
setForgotPasswordEmail('')
setForgotPasswordSent(false)
setForgotPasswordError('')
}
setShowForgotPassword(false);
setForgotPasswordEmail('');
setForgotPasswordSent(false);
setForgotPasswordError('');
};
return (
<div className="min-h-screen flex items-center justify-center py-8 px-4 sm:py-12 sm:px-6 lg:px-8">
<div className="flex min-h-screen items-center justify-center px-4 py-8 sm:px-6 sm:py-12 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">
<div className="fixed right-4 top-4 z-50">
<LanguageSwitcher />
</div>
<div className="relative max-w-md w-full space-y-8">
<div className="relative w-full max-w-md 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 relative">
<div className="relative mx-auto mb-6 flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl bg-gradient-to-br from-accent-400 to-accent-600 shadow-lg shadow-accent-500/30">
{/* Always show letter as fallback */}
<span className={`text-white font-bold text-2xl absolute transition-opacity duration-200 ${branding?.has_custom_logo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}>
<span
className={`absolute text-2xl font-bold text-white transition-opacity duration-200 ${branding?.has_custom_logo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}
>
{appLogo}
</span>
{/* Logo image with smooth fade-in */}
@@ -246,26 +261,30 @@ export default function Login() {
<img
src={logoUrl}
alt={appName || 'Logo'}
className={`w-full h-full object-cover absolute transition-opacity duration-200 ${logoLoaded ? 'opacity-100' : 'opacity-0'}`}
className={`absolute h-full w-full object-cover transition-opacity duration-200 ${logoLoaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setLogoLoaded(true)}
/>
)}
</div>
{appName && (
<h1 className="text-3xl font-bold text-dark-50">
{appName}
</h1>
)}
<p className="mt-2 text-dark-400">
{t('auth.loginSubtitle')}
</p>
{appName && <h1 className="text-3xl font-bold text-dark-50">{appName}</h1>}
<p className="mt-2 text-dark-400">{t('auth.loginSubtitle')}</p>
{/* Referral Banner - only show when email auth is enabled */}
{referralCode && isEmailAuthEnabled && (
<div className="mt-4 p-3 rounded-xl bg-accent-500/10 border border-accent-500/30">
<div className="mt-4 rounded-xl border border-accent-500/30 bg-accent-500/10 p-3">
<div className="flex items-center gap-2 text-accent-400">
<svg className="w-5 h-5 flex-shrink-0" 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
className="h-5 w-5 flex-shrink-0"
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>
<span className="text-sm font-medium">{t('auth.referralInvite')}</span>
</div>
@@ -276,25 +295,38 @@ export default function Login() {
{/* Check Email Screen */}
{registeredEmail ? (
<div className="card text-center">
<div className="w-16 h-16 mx-auto mb-6 rounded-2xl bg-success-500/20 flex items-center justify-center">
<svg className="w-8 h-8 text-success-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-success-500/20">
<svg
className="h-8 w-8 text-success-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
</div>
<h2 className="text-xl font-bold text-dark-50 mb-2">
<h2 className="mb-2 text-xl font-bold text-dark-50">
{t('auth.checkEmail', 'Check your email')}
</h2>
<p className="text-dark-400 mb-4">
<p className="mb-4 text-dark-400">
{t('auth.verificationSent', 'We sent a verification link to:')}
</p>
<p className="text-accent-400 font-medium mb-6">{registeredEmail}</p>
<p className="text-sm text-dark-500 mb-6">
{t('auth.clickLinkToVerify', 'Click the link in the email to verify your account and log in.')}
<p className="mb-6 font-medium text-accent-400">{registeredEmail}</p>
<p className="mb-6 text-sm text-dark-500">
{t(
'auth.clickLinkToVerify',
'Click the link in the email to verify your account and log in.',
)}
</p>
<button
onClick={() => {
setRegisteredEmail(null)
setAuthMode('login')
setRegisteredEmail(null);
setAuthMode('login');
}}
className="btn-secondary w-full"
>
@@ -302,199 +334,198 @@ export default function Login() {
</button>
</div>
) : (
/* Card */
<div className="card">
{/* Tabs */}
{isEmailAuthEnabled ? (
<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>
) : (
<div className="mb-6 pb-3 border-b border-dark-700">
<h2 className="text-lg font-medium text-dark-200 text-center">Telegram</h2>
</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' || !isEmailAuthEnabled ? (
<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>
) : (
<div className="space-y-5">
{/* Login / Register toggle */}
<div className="flex bg-dark-800 rounded-lg p-1">
/* Card */
<div className="card">
{/* Tabs */}
{isEmailAuthEnabled ? (
<div className="mb-6 flex">
<button
type="button"
className={`flex-1 py-2 text-sm font-medium rounded-md transition-all ${
authMode === 'login'
? 'bg-accent-500 text-white'
: 'text-dark-400 hover:text-dark-200'
className={`flex-1 border-b-2 py-3 text-sm font-medium transition-all ${
activeTab === 'telegram'
? 'border-accent-500 text-accent-400'
: 'border-transparent text-dark-500 hover:text-dark-300'
}`}
onClick={() => setAuthMode('login')}
onClick={() => setActiveTab('telegram')}
>
{t('auth.login')}
Telegram
</button>
<button
type="button"
className={`flex-1 py-2 text-sm font-medium rounded-md transition-all ${
authMode === 'register'
? 'bg-accent-500 text-white'
: 'text-dark-400 hover:text-dark-200'
className={`flex-1 border-b-2 py-3 text-sm font-medium transition-all ${
activeTab === 'email'
? 'border-accent-500 text-accent-400'
: 'border-transparent text-dark-500 hover:text-dark-300'
}`}
onClick={() => setAuthMode('register')}
onClick={() => setActiveTab('email')}
>
{t('auth.register', 'Register')}
Email
</button>
</div>
) : (
<div className="mb-6 border-b border-dark-700 pb-3">
<h2 className="text-center text-lg font-medium text-dark-200">Telegram</h2>
</div>
)}
{error && (
<div className="mb-6 rounded-xl border border-error-500/30 bg-error-500/10 px-4 py-3 text-sm text-error-400">
{error}
</div>
)}
{activeTab === 'telegram' || !isEmailAuthEnabled ? (
<div className="space-y-6">
<p className="text-center text-sm text-dark-400">{t('auth.registerHint')}</p>
{isLoading && isTelegramWebApp ? (
<div className="py-8 text-center">
<div className="mx-auto mb-3 h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
<p className="text-sm text-dark-400">{t('auth.authenticating')}</p>
</div>
) : (
<TelegramLoginButton botUsername={botUsername} />
)}
</div>
) : (
<div className="space-y-5">
{/* Login / Register toggle */}
<div className="flex rounded-lg bg-dark-800 p-1">
<button
type="button"
className={`flex-1 rounded-md py-2 text-sm font-medium transition-all ${
authMode === 'login'
? 'bg-accent-500 text-white'
: 'text-dark-400 hover:text-dark-200'
}`}
onClick={() => setAuthMode('login')}
>
{t('auth.login')}
</button>
<button
type="button"
className={`flex-1 rounded-md py-2 text-sm font-medium transition-all ${
authMode === 'register'
? 'bg-accent-500 text-white'
: 'text-dark-400 hover:text-dark-200'
}`}
onClick={() => setAuthMode('register')}
>
{t('auth.register', 'Register')}
</button>
</div>
<form className="space-y-4" onSubmit={handleEmailSubmit}>
{/* First name field - only for registration */}
{authMode === 'register' && (
<div>
<label htmlFor="firstName" className="label">
{t('auth.firstName', 'First Name')}
</label>
<input
id="firstName"
name="firstName"
type="text"
autoComplete="given-name"
className="input"
placeholder={t('auth.firstNamePlaceholder', 'Your name (optional)')}
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
/>
</div>
)}
<form className="space-y-4" onSubmit={handleEmailSubmit}>
{/* First name field - only for registration */}
{authMode === 'register' && (
<div>
<label htmlFor="firstName" className="label">
{t('auth.firstName', 'First Name')}
<label htmlFor="email" className="label">
{t('auth.email')}
</label>
<input
id="firstName"
name="firstName"
type="text"
autoComplete="given-name"
id="email"
name="email"
type="email"
autoComplete="email"
required
className="input"
placeholder={t('auth.firstNamePlaceholder', 'Your name (optional)')}
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
)}
<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={authMode === 'login' ? 'current-password' : 'new-password'}
required
className="input"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{/* Confirm password - only for registration */}
{authMode === 'register' && (
<div>
<label htmlFor="confirmPassword" className="label">
{t('auth.confirmPassword', 'Confirm Password')}
<label htmlFor="password" className="label">
{t('auth.password')}
</label>
<input
id="confirmPassword"
name="confirmPassword"
id="password"
name="password"
type="password"
autoComplete="new-password"
autoComplete={authMode === 'login' ? 'current-password' : 'new-password'}
required
className="input"
placeholder="••••••••"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{/* Confirm password - only for registration */}
{authMode === 'register' && (
<div>
<label htmlFor="confirmPassword" className="label">
{t('auth.confirmPassword', 'Confirm Password')}
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
className="input"
placeholder="••••••••"
value={confirmPassword}
onChange={(e) => setConfirmPassword(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="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
{t('common.loading')}
</span>
) : authMode === 'login' ? (
t('auth.login')
) : (
t('auth.register', 'Register')
)}
</button>
</form>
{/* Verification notice for registration */}
{authMode === 'register' && (
<p className="text-center text-xs text-dark-500">
{t(
'auth.verificationEmailNotice',
'After registration, a verification email will be sent to your address',
)}
</p>
)}
<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>
) : (
authMode === 'login' ? t('auth.login') : t('auth.register', 'Register')
)}
</button>
</form>
{/* Verification notice for registration */}
{authMode === 'register' && (
<p className="text-center text-xs text-dark-500">
{t('auth.verificationEmailNotice', 'After registration, a verification email will be sent to your address')}
</p>
)}
{/* Forgot password link - only for login */}
{authMode === 'login' && (
<div className="text-center space-y-2">
<button
type="button"
onClick={() => setShowForgotPassword(true)}
className="text-sm text-accent-400 hover:text-accent-300 transition-colors"
>
{t('auth.forgotPassword', 'Forgot password?')}
</button>
</div>
)}
</div>
)}
</div>
{/* Forgot password link - only for login */}
{authMode === 'login' && (
<div className="space-y-2 text-center">
<button
type="button"
onClick={() => setShowForgotPassword(true)}
className="text-sm text-accent-400 transition-colors hover:text-accent-300"
>
{t('auth.forgotPassword', 'Forgot password?')}
</button>
</div>
)}
</div>
)}
</div>
)}
</div>
@@ -502,28 +533,47 @@ export default function Login() {
{showForgotPassword && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/60" onClick={closeForgotPasswordModal} />
<div className="relative bg-dark-900 rounded-2xl p-6 w-full max-w-md border border-dark-700">
<div className="relative w-full max-w-md rounded-2xl border border-dark-700 bg-dark-900 p-6">
<button
onClick={closeForgotPasswordModal}
className="absolute top-4 right-4 text-dark-400 hover:text-dark-200 transition-colors"
className="absolute right-4 top-4 text-dark-400 transition-colors hover:text-dark-200"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{forgotPasswordSent ? (
<div className="text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-success-500/20 flex items-center justify-center">
<svg className="w-8 h-8 text-success-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-success-500/20">
<svg
className="h-8 w-8 text-success-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
</div>
<h3 className="text-xl font-bold text-dark-50 mb-2">
<h3 className="mb-2 text-xl font-bold text-dark-50">
{t('auth.checkEmail', 'Check your email')}
</h3>
<p className="text-dark-400 mb-4">
{t('auth.passwordResetSent', 'If an account exists with this email, we sent password reset instructions.')}
<p className="mb-4 text-dark-400">
{t(
'auth.passwordResetSent',
'If an account exists with this email, we sent password reset instructions.',
)}
</p>
<button onClick={closeForgotPasswordModal} className="btn-primary w-full">
{t('common.close', 'Close')}
@@ -531,16 +581,21 @@ export default function Login() {
</div>
) : (
<>
<h3 className="text-xl font-bold text-dark-50 mb-2">
<h3 className="mb-2 text-xl font-bold text-dark-50">
{t('auth.forgotPassword', 'Forgot password?')}
</h3>
<p className="text-dark-400 mb-6">
{t('auth.forgotPasswordHint', 'Enter your email and we will send you instructions to reset your password.')}
<p className="mb-6 text-dark-400">
{t(
'auth.forgotPasswordHint',
'Enter your email and we will send you instructions to reset your password.',
)}
</p>
<form onSubmit={handleForgotPassword} className="space-y-4">
<div>
<label htmlFor="forgotEmail" className="label">Email</label>
<label htmlFor="forgotEmail" className="label">
Email
</label>
<input
id="forgotEmail"
type="email"
@@ -553,7 +608,7 @@ export default function Login() {
</div>
{forgotPasswordError && (
<div className="bg-error-500/10 border border-error-500/30 text-error-400 px-4 py-3 rounded-xl text-sm">
<div className="rounded-xl border border-error-500/30 bg-error-500/10 px-4 py-3 text-sm text-error-400">
{forgotPasswordError}
</div>
)}
@@ -565,7 +620,7 @@ export default function Login() {
>
{forgotPasswordLoading ? (
<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" />
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
{t('common.loading')}
</span>
) : (
@@ -579,5 +634,5 @@ export default function Login() {
</div>
)}
</div>
)
);
}

View File

@@ -1,49 +1,64 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { pollsApi, PollInfo, PollQuestion } from '../api/polls'
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 className="h-6 w-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 className="h-5 w-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}>
<svg className="h-5 w-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 { 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({
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)
setCurrentQuestion(data.question);
setQuestionIndex(data.current_question_index);
setTotalQuestions(data.total_questions);
setCompletionMessage(null);
},
})
});
const answerMutation = useMutation({
mutationFn: ({
@@ -51,30 +66,30 @@ export default function Polls() {
questionId,
optionId,
}: {
responseId: number
questionId: number
optionId: number
responseId: number;
questionId: number;
optionId: number;
}) => pollsApi.answerQuestion(responseId, questionId, optionId),
onSuccess: (data) => {
if (data.is_completed) {
setCurrentQuestion(null)
setCurrentQuestion(null);
setCompletionMessage({
reward: data.reward_granted,
message: data.message || t('polls.completed'),
})
queryClient.invalidateQueries({ queryKey: ['polls'] })
});
queryClient.invalidateQueries({ queryKey: ['polls'] });
} else if (data.next_question) {
setCurrentQuestion(data.next_question)
setQuestionIndex(data.current_question_index || 0)
setTotalQuestions(data.total_questions)
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)
}
setSelectedPoll(poll);
startPollMutation.mutate(poll.response_id);
};
const handleAnswer = (optionId: number) => {
if (selectedPoll && currentQuestion) {
@@ -82,70 +97,75 @@ export default function Polls() {
responseId: selectedPoll.response_id,
questionId: currentQuestion.id,
optionId,
})
});
}
}
};
const handleClosePoll = () => {
setSelectedPoll(null)
setCurrentQuestion(null)
setCompletionMessage(null)
}
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 className="flex min-h-64 items-center justify-center">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
)
);
}
if (error) {
return (
<div className="card bg-red-500/10 border-red-500/20">
<div className="card border-red-500/20 bg-red-500/10">
<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>
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('polls.title')}</h1>
</div>
{/* Poll Modal */}
{selectedPoll && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="card max-w-lg w-full max-h-[80vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<div className="card max-h-[80vh] w-full max-w-lg overflow-y-auto">
<div className="mb-4 flex items-center justify-between">
<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 className="h-6 w-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 className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
)}
{completionMessage && (
<div className="space-y-4">
<div className="p-4 rounded-lg bg-success-500/20 text-success-400 text-center">
<div className="rounded-lg bg-success-500/20 p-4 text-center text-success-400">
<CheckIcon />
<p className="font-medium mt-2">{completionMessage.message}</p>
<p className="mt-2 font-medium">{completionMessage.message}</p>
{completionMessage.reward && (
<p className="text-sm mt-1">
<p className="mt-1 text-sm">
+{completionMessage.reward} {t('polls.reward')}
</p>
)}
</div>
<button onClick={handleClosePoll} className="w-full btn-secondary">
<button onClick={handleClosePoll} className="btn-secondary w-full">
{t('common.close')}
</button>
</div>
@@ -156,9 +176,9 @@ export default function Polls() {
<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="h-2 w-full rounded-full bg-dark-700">
<div
className="bg-accent-500 h-2 rounded-full transition-all"
className="h-2 rounded-full bg-accent-500 transition-all"
style={{ width: `${((questionIndex + 1) / totalQuestions) * 100}%` }}
/>
</div>
@@ -171,7 +191,7 @@ export default function Polls() {
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"
className="w-full rounded-lg bg-dark-700 p-4 text-left transition-colors hover:bg-dark-600 disabled:opacity-50"
>
{option.text}
</button>
@@ -180,7 +200,7 @@ export default function Polls() {
{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 className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
)}
</div>
@@ -196,11 +216,11 @@ export default function Polls() {
<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>
<h3 className="text-lg font-semibold">{poll.title}</h3>
{poll.description && (
<p className="text-dark-400 text-sm mt-1">{poll.description}</p>
<p className="mt-1 text-sm text-dark-400">{poll.description}</p>
)}
<div className="flex items-center gap-4 mt-2 text-sm text-dark-400">
<div className="mt-2 flex items-center gap-4 text-sm text-dark-400">
<span>
{poll.answered_questions}/{poll.total_questions} {t('polls.questions')}
</span>
@@ -216,15 +236,12 @@ export default function Polls() {
<div className="mt-4">
{poll.is_completed ? (
<button disabled className="w-full btn-secondary opacity-50 cursor-not-allowed">
<button disabled className="btn-secondary w-full cursor-not-allowed opacity-50">
<CheckIcon />
<span className="ml-2">{t('polls.completed')}</span>
</button>
) : (
<button
onClick={() => handleStartPoll(poll)}
className="w-full btn-primary"
>
<button onClick={() => handleStartPoll(poll)} className="btn-primary w-full">
{poll.answered_questions > 0 ? t('polls.continue') : t('polls.start')}
</button>
)}
@@ -233,11 +250,11 @@ export default function Polls() {
))}
</div>
) : (
<div className="card text-center py-12">
<div className="card py-12 text-center">
<ClipboardIcon />
<p className="text-dark-400 mt-4">{t('polls.noPolls')}</p>
<p className="mt-4 text-dark-400">{t('polls.noPolls')}</p>
</div>
)}
</div>
)
);
}

View File

@@ -1,221 +1,231 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
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'
import { referralApi } from '../api/referral'
import { brandingApi, type EmailAuthEnabled } from '../api/branding'
import { useState } from 'react';
import { Link } from 'react-router-dom';
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';
import { referralApi } from '../api/referral';
import { brandingApi, type EmailAuthEnabled } from '../api/branding';
// Icons
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 className="h-4 w-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}>
<svg className="h-4 w-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}>
<svg className="h-4 w-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>
)
);
const ArrowRightIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg className="h-4 w-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>
)
);
export default function Profile() {
const { t } = useTranslation()
const { user, setUser } = useAuthStore()
const queryClient = useQueryClient()
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 [copied, setCopied] = useState(false)
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 [copied, setCopied] = useState(false);
// Referral data
const { data: referralInfo } = useQuery({
queryKey: ['referral-info'],
queryFn: referralApi.getReferralInfo,
})
});
const { data: referralTerms } = useQuery({
queryKey: ['referral-terms'],
queryFn: referralApi.getReferralTerms,
})
});
const { data: branding } = useQuery({
queryKey: ['branding'],
queryFn: brandingApi.getBranding,
staleTime: 60000,
})
});
// Check if email auth is enabled
const { data: emailAuthConfig } = useQuery<EmailAuthEnabled>({
queryKey: ['email-auth-enabled'],
queryFn: brandingApi.getEmailAuthEnabled,
staleTime: 60000,
})
const isEmailAuthEnabled = emailAuthConfig?.enabled ?? true
});
const isEmailAuthEnabled = emailAuthConfig?.enabled ?? true;
// Build referral link for cabinet
const referralLink = referralInfo?.referral_code
? `${window.location.origin}/login?ref=${referralInfo.referral_code}`
: ''
: '';
const copyReferralLink = () => {
if (referralLink) {
navigator.clipboard.writeText(referralLink)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
navigator.clipboard.writeText(referralLink);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}
};
const shareReferralLink = () => {
if (!referralLink) return
if (!referralLink) return;
const shareText = t('referral.shareMessage', {
percent: referralInfo?.commission_percent || 0,
botName: branding?.name || import.meta.env.VITE_APP_NAME || 'Cabinet',
})
});
if (navigator.share) {
navigator.share({
title: t('referral.title'),
text: shareText,
url: referralLink,
}).catch(() => {})
return
navigator
.share({
title: t('referral.title'),
text: shareText,
url: referralLink,
})
.catch(() => {});
return;
}
const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(referralLink)}&text=${encodeURIComponent(shareText)}`
window.open(telegramUrl, '_blank', 'noopener,noreferrer')
}
const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(referralLink)}&text=${encodeURIComponent(shareText)}`;
window.open(telegramUrl, '_blank', 'noopener,noreferrer');
};
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'] })
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 } } }) => {
const detail = err.response?.data?.detail
const detail = err.response?.data?.detail;
if (detail?.includes('already registered')) {
setError(t('profile.emailAlreadyRegistered'))
setError(t('profile.emailAlreadyRegistered'));
} else if (detail?.includes('already have a verified email')) {
setError(t('profile.alreadyHaveEmail'))
setError(t('profile.alreadyHaveEmail'));
} else {
setError(detail || t('common.error'))
setError(detail || t('common.error'));
}
setSuccess(null)
setSuccess(null);
},
})
});
const resendVerificationMutation = useMutation({
mutationFn: authApi.resendVerification,
onSuccess: () => {
setSuccess(t('profile.verificationResent'))
setError(null)
setSuccess(t('profile.verificationResent'));
setError(null);
},
onError: (err: { response?: { data?: { detail?: string } } }) => {
setError(err.response?.data?.detail || t('common.error'))
setSuccess(null)
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'] })
queryClient.invalidateQueries({ queryKey: ['notification-settings'] });
},
})
});
const handleNotificationToggle = (key: keyof NotificationSettings, value: boolean) => {
const update: NotificationSettingsUpdate = { [key]: value }
updateNotificationsMutation.mutate(update)
}
const update: NotificationSettingsUpdate = { [key]: value };
updateNotificationsMutation.mutate(update);
};
const handleNotificationValue = (key: keyof NotificationSettings, value: number) => {
const update: NotificationSettingsUpdate = { [key]: value }
updateNotificationsMutation.mutate(update)
}
const update: NotificationSettingsUpdate = { [key]: value };
updateNotificationsMutation.mutate(update);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setSuccess(null)
e.preventDefault();
setError(null);
setSuccess(null);
// Валидация email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!email.trim() || !emailRegex.test(email.trim())) {
setError(t('profile.invalidEmail', 'Please enter a valid email address'))
return
setError(t('profile.invalidEmail', 'Please enter a valid email address'));
return;
}
if (!password || password.length < 8) {
setError(t('profile.passwordMinLength'))
return
setError(t('profile.passwordMinLength'));
return;
}
if (password !== confirmPassword) {
setError(t('profile.passwordsMismatch'))
return
setError(t('profile.passwordsMismatch'));
return;
}
registerEmailMutation.mutate({ email, password })
}
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>
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('profile.title')}</h1>
{/* User Info Card */}
<div className="bento-card">
<h2 className="text-lg font-semibold text-dark-100 mb-6">{t('profile.accountInfo')}</h2>
<h2 className="mb-6 text-lg font-semibold text-dark-100">{t('profile.accountInfo')}</h2>
<div className="space-y-4">
<div className="flex justify-between items-center py-3 border-b border-dark-800/50">
<div className="flex items-center justify-between border-b border-dark-800/50 py-3">
<span className="text-dark-400">{t('profile.telegramId')}</span>
<span className="text-dark-100 font-medium">{user?.telegram_id}</span>
<span className="font-medium text-dark-100">{user?.telegram_id}</span>
</div>
{user?.username && (
<div className="flex justify-between items-center py-3 border-b border-dark-800/50">
<div className="flex items-center justify-between border-b border-dark-800/50 py-3">
<span className="text-dark-400">{t('profile.username')}</span>
<span className="text-dark-100 font-medium">@{user.username}</span>
<span className="font-medium text-dark-100">@{user.username}</span>
</div>
)}
<div className="flex justify-between items-center py-3 border-b border-dark-800/50">
<div className="flex items-center justify-between border-b border-dark-800/50 py-3">
<span className="text-dark-400">{t('profile.name')}</span>
<span className="text-dark-100 font-medium">
<span className="font-medium text-dark-100">
{user?.first_name} {user?.last_name}
</span>
</div>
<div className="flex justify-between items-center py-3">
<div className="flex items-center justify-between py-3">
<span className="text-dark-400">{t('profile.registeredAt')}</span>
<span className="text-dark-100 font-medium">
<span className="font-medium text-dark-100">
{user?.created_at ? new Date(user.created_at).toLocaleDateString() : '-'}
</span>
</div>
@@ -225,26 +235,24 @@ export default function Profile() {
{/* Referral Link Widget */}
{referralTerms?.is_enabled && referralLink && (
<div className="bento-card">
<div className="flex items-center justify-between mb-4">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-dark-100">{t('referral.yourLink')}</h2>
<Link to="/referral" className="text-accent-400 hover:text-accent-300 transition-colors flex items-center gap-1">
<Link
to="/referral"
className="flex items-center gap-1 text-accent-400 transition-colors hover:text-accent-300"
>
<span className="text-sm">{t('referral.title')}</span>
<ArrowRightIcon />
</Link>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<div className="flex-1">
<input
type="text"
readOnly
value={referralLink}
className="input w-full text-sm"
/>
<input type="text" readOnly value={referralLink} className="input w-full text-sm" />
</div>
<div className="flex gap-2">
<button
onClick={copyReferralLink}
className={`btn-primary px-4 py-2 flex items-center gap-2 text-sm ${
className={`btn-primary flex items-center gap-2 px-4 py-2 text-sm ${
copied ? 'bg-success-500 hover:bg-success-500' : ''
}`}
>
@@ -253,7 +261,7 @@ export default function Profile() {
</button>
<button
onClick={shareReferralLink}
className="btn-secondary px-4 py-2 flex items-center gap-2 text-sm"
className="btn-secondary flex items-center gap-2 px-4 py-2 text-sm"
>
<ShareIcon />
<span className="hidden sm:inline">{t('referral.shareButton')}</span>
@@ -268,137 +276,139 @@ export default function Profile() {
{/* Email Section - only show when email auth is enabled */}
{isEmailAuthEnabled && (
<div className="bento-card">
<h2 className="text-lg font-semibold text-dark-100 mb-6">{t('profile.emailAuth')}</h2>
<div className="bento-card">
<h2 className="mb-6 text-lg font-semibold text-dark-100">{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>
)}
{user?.email ? (
<div className="space-y-4">
<div className="flex items-center justify-between border-b border-dark-800/50 py-3">
<span className="text-dark-400">Email</span>
<div className="flex items-center gap-3">
<span className="font-medium text-dark-100">{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="rounded-xl border border-warning-500/30 bg-warning-500/10 p-4">
<p className="mb-4 text-sm text-warning-400">
{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="mb-6 text-sm text-dark-400">{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="mt-2 text-xs text-dark-500">{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="rounded-xl border border-error-500/30 bg-error-500/10 p-4 text-sm text-error-400">
{error}
</div>
)}
{success && (
<div className="rounded-xl border border-success-500/30 bg-success-500/10 p-4 text-sm text-success-400">
{success}
</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"
type="submit"
disabled={registerEmailMutation.isPending}
className="btn-primary w-full"
>
{resendVerificationMutation.isPending
? t('common.loading')
: t('profile.resendVerification')}
{registerEmailMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
{t('common.loading')}
</span>
) : (
t('profile.linkEmail')
)}
</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>
</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">
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-4 text-sm text-error-400">
{error}
</div>
)}
{success && (
<div className="bg-success-500/10 border border-success-500/30 text-success-400 p-4 rounded-xl text-sm">
<div className="rounded-xl border border-success-500/30 bg-success-500/10 p-4 text-sm text-success-400">
{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>
</div>
)}
</div>
)}
{/* Notification Settings */}
<div className="bento-card">
<h2 className="text-lg font-semibold text-dark-100 mb-6">{t('profile.notifications.title')}</h2>
<h2 className="mb-6 text-lg font-semibold text-dark-100">
{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 className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : notificationSettings ? (
<div className="space-y-6">
@@ -406,17 +416,28 @@ export default function Profile() {
<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>
<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'
onClick={() =>
handleNotificationToggle(
'subscription_expiry_enabled',
!notificationSettings.subscription_expiry_enabled,
)
}
className={`relative h-6 w-12 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 ${
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
notificationSettings.subscription_expiry_enabled ? 'left-7' : 'left-1'
}`}
/>
@@ -424,14 +445,20 @@ export default function Profile() {
</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>
<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))}
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>
<option key={d} value={d}>
{d}
</option>
))}
</select>
</div>
@@ -442,17 +469,26 @@ export default function Profile() {
<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>
<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 ${
onClick={() =>
handleNotificationToggle(
'traffic_warning_enabled',
!notificationSettings.traffic_warning_enabled,
)
}
className={`relative h-6 w-12 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 ${
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
notificationSettings.traffic_warning_enabled ? 'left-7' : 'left-1'
}`}
/>
@@ -460,14 +496,20 @@ export default function Profile() {
</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>
<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))}
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>
<option key={p} value={p}>
{p}%
</option>
))}
</select>
</div>
@@ -478,17 +520,26 @@ export default function Profile() {
<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>
<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 ${
onClick={() =>
handleNotificationToggle(
'balance_low_enabled',
!notificationSettings.balance_low_enabled,
)
}
className={`relative h-6 w-12 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 ${
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
notificationSettings.balance_low_enabled ? 'left-7' : 'left-1'
}`}
/>
@@ -496,11 +547,15 @@ export default function Profile() {
</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>
<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))}
onChange={(e) =>
handleNotificationValue('balance_low_threshold', Number(e.target.value))
}
min={0}
className="input w-24 py-1"
/>
@@ -515,13 +570,15 @@ export default function Profile() {
<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 ${
onClick={() =>
handleNotificationToggle('news_enabled', !notificationSettings.news_enabled)
}
className={`relative h-6 w-12 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 ${
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
notificationSettings.news_enabled ? 'left-7' : 'left-1'
}`}
/>
@@ -531,17 +588,26 @@ export default function Profile() {
{/* 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>
<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 ${
onClick={() =>
handleNotificationToggle(
'promo_offers_enabled',
!notificationSettings.promo_offers_enabled,
)
}
className={`relative h-6 w-12 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 ${
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
notificationSettings.promo_offers_enabled ? 'left-7' : 'left-1'
}`}
/>
@@ -553,5 +619,5 @@ export default function Profile() {
)}
</div>
</div>
)
);
}

View File

@@ -1,376 +1,306 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { referralApi } from '../api/referral'
import { brandingApi } from '../api/branding'
import { useCurrency } from '../hooks/useCurrency'
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { referralApi } from '../api/referral';
import { brandingApi } from '../api/branding';
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>
)
<svg className="h-4 w-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>
)
<svg className="h-4 w-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>
)
<svg className="h-4 w-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 { 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: info, isLoading } = useQuery({
queryKey: ['referral-info'],
queryFn: referralApi.getReferralInfo,
});
// Build referral link for cabinet registration
const referralLink = info?.referral_code
? `${window.location.origin}/login?ref=${info.referral_code}`
: ''
// Build referral link for cabinet registration
const referralLink = info?.referral_code
? `${window.location.origin}/login?ref=${info.referral_code}`
: '';
const { data: terms } = useQuery({
queryKey: ['referral-terms'],
queryFn: referralApi.getReferralTerms,
})
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: 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 { data: earnings } = useQuery({
queryKey: ['referral-earnings'],
queryFn: () => referralApi.getReferralEarnings({ per_page: 10 }),
});
const { data: branding } = useQuery({
queryKey: ['branding'],
queryFn: brandingApi.getBranding,
staleTime: 60000,
})
const { data: branding } = useQuery({
queryKey: ['branding'],
queryFn: brandingApi.getBranding,
staleTime: 60000,
});
const copyLink = () => {
if (referralLink) {
navigator.clipboard.writeText(referralLink)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
const copyLink = () => {
if (referralLink) {
navigator.clipboard.writeText(referralLink);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const shareLink = () => {
if (!referralLink) return
const shareText = t('referral.shareMessage', {
percent: info?.commission_percent || 0,
botName: branding?.name || import.meta.env.VITE_APP_NAME || 'Cabinet',
})
const shareLink = () => {
if (!referralLink) return;
const shareText = t('referral.shareMessage', {
percent: info?.commission_percent || 0,
botName: branding?.name || import.meta.env.VITE_APP_NAME || 'Cabinet',
});
if (navigator.share) {
navigator
.share({
title: t('referral.title'),
text: shareText,
url: referralLink,
})
.catch(() => {
// ignore cancellation errors
})
return
}
if (navigator.share) {
navigator
.share({
title: t('referral.title'),
text: shareText,
url: referralLink,
})
.catch(() => {
// ignore cancellation errors
});
return;
}
const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(
referralLink
)}&text=${encodeURIComponent(shareText)}`
window.open(telegramUrl, '_blank', 'noopener,noreferrer')
}
const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(
referralLink,
)}&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>
)
}
if (isLoading) {
return (
<div className="flex min-h-64 items-center justify-center">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
);
}
// Show disabled state if referral program is disabled
if (terms && !terms.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'>
<svg
className='w-12 h-12 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-center'>
<h1 className='text-2xl font-bold text-dark-100 mb-2'>
{t('referral.title')}
</h1>
<p className='text-dark-400'>{t('referral.disabled')}</p>
</div>
</div>
)
}
// Show disabled state if referral program is disabled
if (terms && !terms.is_enabled) {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-6">
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-dark-800">
<svg
className="h-12 w-12 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-center">
<h1 className="mb-2 text-2xl font-bold text-dark-100">{t('referral.title')}</h1>
<p className="text-dark-400">{t('referral.disabled')}</p>
</div>
</div>
);
}
return (
<div className='space-y-6'>
<h1 className='text-2xl sm:text-3xl font-bold text-dark-50'>
{t('referral.title')}
</h1>
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('referral.title')}</h1>
{/* Stats Cards */}
<div className='bento-grid'>
<div className='bento-card-hover'>
<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='bento-card-hover'>
<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='bento-card-hover 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>
{/* Stats Cards */}
<div className="bento-grid">
<div className="bento-card-hover">
<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="mt-1 text-sm text-dark-500">
{info?.active_referrals || 0} {t('referral.stats.activeReferrals').toLowerCase()}
</div>
</div>
<div className="bento-card-hover">
<div className="text-sm text-dark-400">{t('referral.stats.totalEarnings')}</div>
<div className="stat-value mt-1 text-success-400">
{formatPositive(info?.total_earnings_rubles || 0)}
</div>
</div>
<div className="bento-card-hover col-span-2 sm:col-span-1">
<div className="text-sm text-dark-400">{t('referral.stats.commissionRate')}</div>
<div className="stat-value mt-1 text-accent-400">{info?.commission_percent || 0}%</div>
</div>
</div>
{/* Referral Link */}
<div className='bento-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={referralLink}
className='input flex-1'
/>
<div className='flex gap-2'>
<button
onClick={copyLink}
disabled={!referralLink}
className={`btn-primary px-5 ${
copied ? 'bg-success-500 hover:bg-success-500' : ''
} ${!referralLink ? '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={!referralLink}
className={`btn-secondary px-5 flex items-center ${
!referralLink ? '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>
{/* Referral Link */}
<div className="bento-card">
<h2 className="mb-4 text-lg font-semibold text-dark-100">{t('referral.yourLink')}</h2>
<div className="flex flex-col gap-2 sm:flex-row">
<input type="text" readOnly value={referralLink} className="input flex-1" />
<div className="flex gap-2">
<button
onClick={copyLink}
disabled={!referralLink}
className={`btn-primary px-5 ${
copied ? 'bg-success-500 hover:bg-success-500' : ''
} ${!referralLink ? 'cursor-not-allowed opacity-50' : ''}`}
>
{copied ? <CheckIcon /> : <CopyIcon />}
<span className="ml-2">{copied ? t('referral.copied') : t('referral.copyLink')}</span>
</button>
<button
onClick={shareLink}
disabled={!referralLink}
className={`btn-secondary flex items-center px-5 ${
!referralLink ? 'cursor-not-allowed opacity-50' : ''
}`}
>
<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='bento-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>
)}
{/* Program Terms */}
{terms && (
<div className="bento-card">
<h2 className="mb-4 text-lg font-semibold text-dark-100">{t('referral.terms.title')}</h2>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div className="rounded-xl bg-dark-800/30 p-3">
<div className="text-sm text-dark-500">{t('referral.terms.commission')}</div>
<div className="mt-1 text-lg font-semibold text-dark-100">
{terms.commission_percent}%
</div>
</div>
<div className="rounded-xl bg-dark-800/30 p-3">
<div className="text-sm text-dark-500">{t('referral.terms.minTopup')}</div>
<div className="mt-1 text-lg font-semibold text-dark-100">
{formatAmount(terms.minimum_topup_rubles)} {currencySymbol}
</div>
</div>
<div className="rounded-xl bg-dark-800/30 p-3">
<div className="text-sm text-dark-500">{t('referral.terms.newUserBonus')}</div>
<div className="mt-1 text-lg font-semibold text-success-400">
{formatPositive(terms.first_topup_bonus_rubles)}
</div>
</div>
<div className="rounded-xl bg-dark-800/30 p-3">
<div className="text-sm text-dark-500">{t('referral.terms.inviterBonus')}</div>
<div className="mt-1 text-lg font-semibold text-success-400">
{formatPositive(terms.inviter_bonus_rubles)}
</div>
</div>
</div>
</div>
)}
{/* Referrals List */}
<div className='bento-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'>
{t('referral.status.paid')}
</span>
) : (
<span className='badge-neutral'>
{t('referral.status.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>
{/* Referrals List */}
<div className="bento-card">
<h2 className="mb-4 text-lg font-semibold text-dark-100">{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 rounded-xl border border-dark-700/30 bg-dark-800/30 p-3"
>
<div>
<div className="font-medium text-dark-100">
{ref.first_name || ref.username || `User #${ref.id}`}
</div>
<div className="mt-0.5 text-xs text-dark-500">
{new Date(ref.created_at).toLocaleDateString()}
</div>
</div>
{ref.has_paid ? (
<span className="badge-success">{t('referral.status.paid')}</span>
) : (
<span className="badge-neutral">{t('referral.status.pending')}</span>
)}
</div>
))}
</div>
) : (
<div className="py-12 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">
<svg
className="h-8 w-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='bento-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'>
{t(`referral.reasons.${earning.reason}`, 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>
)
{/* Earnings History */}
{earnings?.items && earnings.items.length > 0 && (
<div className="bento-card">
<h2 className="mb-4 text-lg font-semibold text-dark-100">
{t('referral.earningsHistory')}
</h2>
<div className="space-y-3">
{earnings.items.map((earning) => (
<div
key={earning.id}
className="flex items-center justify-between rounded-xl border border-dark-700/30 bg-dark-800/30 p-3"
>
<div>
<div className="text-dark-100">
{earning.referral_first_name || earning.referral_username || 'Referral'}
</div>
<div className="mt-0.5 text-xs text-dark-500">
{t(`referral.reasons.${earning.reason}`, earning.reason)} {' '}
{new Date(earning.created_at).toLocaleDateString()}
</div>
</div>
<div className="font-semibold text-success-400">
{formatPositive(earning.amount_rubles)}
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,108 +1,117 @@
import { useState } from 'react'
import { useSearchParams, Link, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { authApi } from '../api/auth'
import LanguageSwitcher from '../components/LanguageSwitcher'
import { useState } from 'react';
import { useSearchParams, Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { authApi } from '../api/auth';
import LanguageSwitcher from '../components/LanguageSwitcher';
export default function ResetPassword() {
const { t } = useTranslation()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const token = searchParams.get('token')
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const token = searchParams.get('token');
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [status, setStatus] = useState<'form' | 'loading' | 'success' | 'error'>('form')
const [error, setError] = useState('')
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [status, setStatus] = useState<'form' | 'loading' | 'success' | 'error'>('form');
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
e.preventDefault();
setError('');
if (!token) {
setError(t('resetPassword.invalidToken', 'Invalid or missing reset token'))
return
setError(t('resetPassword.invalidToken', 'Invalid or missing reset token'));
return;
}
if (password.length < 8) {
setError(t('auth.passwordTooShort', 'Password must be at least 8 characters'))
return
setError(t('auth.passwordTooShort', 'Password must be at least 8 characters'));
return;
}
if (password !== confirmPassword) {
setError(t('auth.passwordMismatch', 'Passwords do not match'))
return
setError(t('auth.passwordMismatch', 'Passwords do not match'));
return;
}
setStatus('loading')
setStatus('loading');
try {
await authApi.resetPassword(token, password)
setStatus('success')
setTimeout(() => navigate('/login', { replace: true }), 2000)
await authApi.resetPassword(token, password);
setStatus('success');
setTimeout(() => navigate('/login', { replace: true }), 2000);
} catch (err: unknown) {
setStatus('error')
const error = err as { response?: { data?: { detail?: string } } }
setError(error.response?.data?.detail || t('common.error'))
setStatus('error');
const error = err as { response?: { data?: { detail?: string } } };
setError(error.response?.data?.detail || t('common.error'));
}
}
};
if (!token) {
return (
<div className="min-h-screen flex items-center justify-center py-8 px-4 sm:py-12">
<div className="flex min-h-screen items-center justify-center px-4 py-8 sm:py-12">
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
<div className="fixed top-4 right-4 z-50">
<div className="fixed right-4 top-4 z-50">
<LanguageSwitcher />
</div>
<div className="relative max-w-md w-full text-center">
<div className="relative w-full max-w-md text-center">
<div className="card">
<div className="text-error-400 text-5xl mb-4">!</div>
<h2 className="text-xl font-semibold text-dark-50 mb-2">
<div className="mb-4 text-5xl text-error-400">!</div>
<h2 className="mb-2 text-xl font-semibold text-dark-50">
{t('resetPassword.invalidToken', 'Invalid reset link')}
</h2>
<p className="text-dark-400 mb-6">
{t('resetPassword.tokenExpiredOrInvalid', 'This password reset link is invalid or has expired.')}
<p className="mb-6 text-dark-400">
{t(
'resetPassword.tokenExpiredOrInvalid',
'This password reset link is invalid or has expired.',
)}
</p>
<Link to="/login" className="btn-primary w-full inline-block">
<Link to="/login" className="btn-primary inline-block w-full">
{t('auth.backToLogin', 'Back to login')}
</Link>
</div>
</div>
</div>
)
);
}
return (
<div className="min-h-screen flex items-center justify-center py-8 px-4 sm:py-12">
<div className="flex min-h-screen items-center justify-center px-4 py-8 sm:py-12">
<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="fixed top-4 right-4 z-50">
<div className="fixed right-4 top-4 z-50">
<LanguageSwitcher />
</div>
<div className="relative max-w-md w-full">
<div className="relative w-full max-w-md">
<div className="card">
{status === 'success' ? (
<div className="text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-success-500/20 flex items-center justify-center">
<svg className="w-8 h-8 text-success-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-success-500/20">
<svg
className="h-8 w-8 text-success-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-xl font-bold text-dark-50 mb-2">
<h2 className="mb-2 text-xl font-bold text-dark-50">
{t('resetPassword.success', 'Password changed!')}
</h2>
<p className="text-dark-400 mb-4">
<p className="mb-4 text-dark-400">
{t('resetPassword.redirectingToLogin', 'Redirecting to login...')}
</p>
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" />
<div className="mx-auto h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : (
<>
<h2 className="text-xl font-bold text-dark-50 mb-2 text-center">
<h2 className="mb-2 text-center text-xl font-bold text-dark-50">
{t('resetPassword.title', 'Set new password')}
</h2>
<p className="text-dark-400 mb-6 text-center">
<p className="mb-6 text-center text-dark-400">
{t('resetPassword.enterNewPassword', 'Enter your new password below.')}
</p>
@@ -140,7 +149,7 @@ export default function ResetPassword() {
</div>
{error && (
<div className="bg-error-500/10 border border-error-500/30 text-error-400 px-4 py-3 rounded-xl text-sm">
<div className="rounded-xl border border-error-500/30 bg-error-500/10 px-4 py-3 text-sm text-error-400">
{error}
</div>
)}
@@ -152,7 +161,7 @@ export default function ResetPassword() {
>
{status === 'loading' ? (
<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" />
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
{t('common.loading')}
</span>
) : (
@@ -162,7 +171,10 @@ export default function ResetPassword() {
</form>
<div className="mt-4 text-center">
<Link to="/login" className="text-sm text-dark-400 hover:text-dark-200 transition-colors">
<Link
to="/login"
className="text-sm text-dark-400 transition-colors hover:text-dark-200"
>
{t('auth.backToLogin', 'Back to login')}
</Link>
</div>
@@ -171,5 +183,5 @@ export default function ResetPassword() {
</div>
</div>
</div>
)
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,95 +1,103 @@
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 { logger } from '../utils/logger'
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit'
import type { TicketDetail, TicketMessage } from '../types'
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 { logger } from '../utils/logger';
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit';
import type { TicketDetail, TicketMessage } from '../types';
const log = logger.createLogger('Support')
const log = logger.createLogger('Support');
const PlusIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg className="h-5 w-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 className="h-4 w-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 className="h-5 w-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}>
<svg className="h-4 w-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
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)
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
return null;
}
const mediaUrl = ticketsApi.getMediaUrl(message.media_file_id)
const mediaUrl = ticketsApi.getMediaUrl(message.media_file_id);
if (message.media_type === 'photo') {
return (
<>
<div className="mt-3 relative">
<div className="relative mt-3">
{!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 className="flex h-48 w-full animate-pulse items-center justify-center rounded-lg bg-dark-700">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
)}
{imageError ? (
<div className="w-full h-32 bg-dark-700 rounded-lg flex items-center justify-center text-dark-400 text-sm">
<div className="flex h-32 w-full items-center justify-center rounded-lg bg-dark-700 text-sm text-dark-400">
{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'}`}
className={`max-h-64 max-w-full cursor-pointer rounded-lg transition-opacity hover:opacity-90 ${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>
<p className="mt-1 text-xs text-dark-400">{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"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4"
onClick={() => setShowFullImage(false)}
>
<button
className="absolute top-4 right-4 text-white/70 hover:text-white"
className="absolute right-4 top-4 text-white/70 hover:text-white"
onClick={() => setShowFullImage(false)}
>
<CloseIcon />
@@ -97,12 +105,12 @@ function MessageMedia({ message, t }: { message: TicketMessage; t: (key: string)
<img
src={mediaUrl}
alt={message.media_caption || 'Attached image'}
className="max-w-full max-h-full object-contain"
className="max-h-full max-w-full object-contain"
/>
</div>
)}
</>
)
);
}
// For documents/videos - show download link
@@ -112,68 +120,78 @@ function MessageMedia({ message, t }: { message: TicketMessage; t: (key: string)
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"
className="inline-flex items-center gap-2 rounded-lg bg-dark-700 px-3 py-2 text-sm text-dark-200 transition-colors hover:bg-dark-600"
>
<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
className="h-4 w-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() {
log.debug('Component loaded')
log.debug('Component loaded');
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('')
const [rateLimitError, setRateLimitError] = useState<string | null>(null)
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('');
const [rateLimitError, setRateLimitError] = useState<string | null>(null);
// 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)
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
setAttachment: (a: MediaAttachment | null) => void,
) => {
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
setAttachment({
file,
preview: '',
uploading: false,
error: t('support.invalidFileType') || 'Invalid file type. Use JPEG, PNG, GIF, or WebP.',
})
return
});
return;
}
// Validate file size (10MB)
@@ -183,31 +201,31 @@ export default function Support() {
preview: '',
uploading: false,
error: t('support.fileTooLarge') || 'File is too large. Maximum size is 10MB.',
})
return
});
return;
}
// Create preview
const preview = URL.createObjectURL(file)
setAttachment({ file, preview, uploading: true })
const preview = URL.createObjectURL(file);
setAttachment({ file, preview, uploading: true });
try {
const result = await ticketsApi.uploadMedia(file, 'photo')
const result = await ticketsApi.uploadMedia(file, 'photo');
setAttachment({
file,
preview,
uploading: false,
fileId: result.file_id,
})
} catch (error) {
});
} catch {
setAttachment({
file,
preview,
uploading: false,
error: t('support.uploadFailed') || 'Failed to upload image',
})
});
}
}
};
const createMutation = useMutation({
mutationFn: async () => {
@@ -216,18 +234,18 @@ export default function Support() {
media_type: 'photo',
media_file_id: createAttachment.fileId,
}
: undefined
return ticketsApi.createTicket(newTitle, newMessage, media)
: undefined;
return ticketsApi.createTicket(newTitle, newMessage, media);
},
onSuccess: (ticket) => {
queryClient.invalidateQueries({ queryKey: ['tickets'] })
setShowCreateForm(false)
setNewTitle('')
setNewMessage('')
setCreateAttachment(null)
setSelectedTicket(ticket)
queryClient.invalidateQueries({ queryKey: ['tickets'] });
setShowCreateForm(false);
setNewTitle('');
setNewMessage('');
setCreateAttachment(null);
setSelectedTicket(ticket);
},
})
});
const replyMutation = useMutation({
mutationFn: async () => {
@@ -236,100 +254,100 @@ export default function Support() {
media_type: 'photo',
media_file_id: replyAttachment.fileId,
}
: undefined
return ticketsApi.addMessage(selectedTicket!.id, replyMessage, media)
: undefined;
return ticketsApi.addMessage(selectedTicket!.id, replyMessage, media);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ticket', selectedTicket?.id] })
setReplyMessage('')
setReplyAttachment(null)
queryClient.invalidateQueries({ queryKey: ['ticket', selectedTicket?.id] });
setReplyMessage('');
setReplyAttachment(null);
},
})
});
const getStatusBadge = (status: string) => {
switch (status) {
case 'open':
return 'badge-info'
return 'badge-info';
case 'answered':
return 'badge-success'
return 'badge-success';
case 'pending':
return 'badge-warning'
return 'badge-warning';
case 'closed':
return 'badge-neutral'
return 'badge-neutral';
default:
return 'badge-neutral'
return 'badge-neutral';
}
}
};
const getStatusLabel = (status: string) => {
return t(`support.status.${status}`) || status
}
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 className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
)
);
}
// If tickets are disabled, show redirect message
if (supportConfig && !supportConfig.tickets_enabled) {
log.debug('Tickets disabled, config:', supportConfig)
log.debug('Tickets disabled, config:', supportConfig);
const getSupportMessage = () => {
log.debug('Getting support message for type:', supportConfig.support_type)
log.debug('Getting support message for type:', supportConfig.support_type);
if (supportConfig.support_type === 'profile') {
const supportUsername = supportConfig.support_username || '@support'
log.debug('Opening profile:', supportUsername)
const supportUsername = supportConfig.support_username || '@support';
log.debug('Opening profile:', supportUsername);
return {
title: t('support.ticketsDisabled'),
message: t('support.contactSupport', { username: supportUsername }),
buttonText: t('support.contactUs'),
buttonAction: () => {
log.debug('Button clicked, opening:', supportUsername)
const webApp = window.Telegram?.WebApp
log.debug('Button clicked, opening:', supportUsername);
const webApp = window.Telegram?.WebApp;
// Extract username without @
const username = supportUsername.startsWith('@')
? supportUsername.slice(1)
: supportUsername
: supportUsername;
const webUrl = `https://t.me/${username}`
const webUrl = `https://t.me/${username}`;
log.debug('Web URL:', webUrl, 'WebApp methods:', {
openTelegramLink: !!webApp?.openTelegramLink,
openLink: !!webApp?.openLink,
})
});
// Try openTelegramLink first (for tg:// links)
if (webApp?.openTelegramLink) {
log.debug('Using openTelegramLink with web URL')
log.debug('Using openTelegramLink with web URL');
try {
webApp.openTelegramLink(webUrl)
return
webApp.openTelegramLink(webUrl);
return;
} catch (e) {
log.error('openTelegramLink failed:', e)
log.error('openTelegramLink failed:', e);
}
}
// Fallback to openLink
if (webApp?.openLink) {
log.debug('Using openLink')
log.debug('Using openLink');
try {
webApp.openLink(webUrl, { try_browser: true })
return
webApp.openLink(webUrl, { try_browser: true });
return;
} catch (e) {
log.error('openLink failed:', e)
log.error('openLink failed:', e);
}
}
// Last resort - window.open
log.debug('Using window.open')
window.open(webUrl, '_blank')
log.debug('Using window.open');
window.open(webUrl, '_blank');
},
}
};
}
if (supportConfig.support_type === 'url' && supportConfig.support_url) {
@@ -338,67 +356,77 @@ export default function Support() {
message: t('support.useExternalLink'),
buttonText: t('support.openSupport'),
buttonAction: () => {
const webApp = window.Telegram?.WebApp
const webApp = window.Telegram?.WebApp;
if (webApp?.openLink) {
webApp.openLink(supportConfig.support_url!, { try_browser: true })
webApp.openLink(supportConfig.support_url!, { try_browser: true });
} else {
window.open(supportConfig.support_url!, '_blank')
window.open(supportConfig.support_url!, '_blank');
}
},
}
};
}
// Fallback: contact support (should not normally happen if config is correct)
const supportUsername = supportConfig.support_username || '@support'
log.debug('Fallback: Opening profile:', supportUsername)
const supportUsername = supportConfig.support_username || '@support';
log.debug('Fallback: Opening profile:', supportUsername);
return {
title: t('support.ticketsDisabled'),
message: t('support.contactSupport', { username: supportUsername }),
buttonText: t('support.contactUs'),
buttonAction: () => {
log.debug('Fallback button clicked, opening:', supportUsername)
const webApp = window.Telegram?.WebApp
log.debug('Fallback button clicked, opening:', supportUsername);
const webApp = window.Telegram?.WebApp;
// Extract username without @
const username = supportUsername.startsWith('@')
? supportUsername.slice(1)
: supportUsername
: supportUsername;
const webUrl = `https://t.me/${username}`
log.debug('Fallback opening URL:', webUrl)
const webUrl = `https://t.me/${username}`;
log.debug('Fallback opening URL:', webUrl);
if (webApp?.openTelegramLink) {
log.debug('Fallback using openTelegramLink')
webApp.openTelegramLink(webUrl)
log.debug('Fallback using openTelegramLink');
webApp.openTelegramLink(webUrl);
} else if (webApp?.openLink) {
log.debug('Fallback using openLink')
webApp.openLink(webUrl, { try_browser: true })
log.debug('Fallback using openLink');
webApp.openLink(webUrl, { try_browser: true });
} else {
log.debug('Fallback using window.open')
window.open(webUrl, '_blank')
log.debug('Fallback using window.open');
window.open(webUrl, '_blank');
}
},
}
}
};
};
const supportMessage = getSupportMessage()
const supportMessage = getSupportMessage();
return (
<div className="max-w-md mx-auto mt-12">
<div className="mx-auto mt-12 max-w-md">
<div className="bento-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" />
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">
<svg
className="h-8 w-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>
<h2 className="mb-2 text-xl font-semibold text-dark-100">{supportMessage.title}</h2>
<p className="mb-6 text-dark-400">{supportMessage.message}</p>
<button onClick={supportMessage.buttonAction} className="btn-primary w-full">
{supportMessage.buttonText}
</button>
</div>
</div>
)
);
}
// Attachment preview component
@@ -406,10 +434,10 @@ export default function Support() {
attachment,
onRemove,
}: {
attachment: MediaAttachment
onRemove: () => void
attachment: MediaAttachment;
onRemove: () => void;
}) => (
<div className="relative inline-block mt-2">
<div className="relative mt-2 inline-block">
{attachment.preview && (
<img
src={attachment.preview}
@@ -418,32 +446,30 @@ export default function Support() {
/>
)}
{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 className="absolute inset-0 flex items-center justify-center rounded-lg bg-dark-900/70">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
)}
{attachment.error && (
<div className="text-xs text-red-400 mt-1">{attachment.error}</div>
)}
{attachment.error && <div className="mt-1 text-xs text-red-400">{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"
className="absolute -right-2 -top-2 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 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>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('support.title')}</h1>
<button
onClick={() => {
setShowCreateForm(true)
setSelectedTicket(null)
setCreateAttachment(null)
setShowCreateForm(true);
setSelectedTicket(null);
setCreateAttachment(null);
}}
className="btn-primary"
>
@@ -452,14 +478,14 @@ export default function Support() {
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Tickets List */}
<div className="lg:col-span-1 bento-card">
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('support.yourTickets')}</h2>
<div className="bento-card lg:col-span-1">
<h2 className="mb-4 text-lg font-semibold text-dark-100">{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 className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : tickets?.items && tickets.items.length > 0 ? (
<div className="space-y-2">
@@ -467,18 +493,18 @@ export default function Support() {
<button
key={ticket.id}
onClick={() => {
setSelectedTicket(ticket as unknown as TicketDetail)
setShowCreateForm(false)
setReplyAttachment(null)
setSelectedTicket(ticket as unknown as TicketDetail);
setShowCreateForm(false);
setReplyAttachment(null);
}}
className={`w-full text-left p-4 rounded-bento border transition-all ${
className={`w-full rounded-bento border p-4 text-left 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'
: 'border-dark-700/50 bg-dark-800/30 hover:border-dark-600'
}`}
>
<div className="flex justify-between items-start gap-2 mb-2">
<div className="text-dark-100 font-medium truncate">{ticket.title}</div>
<div className="mb-2 flex items-start justify-between gap-2">
<div className="truncate font-medium text-dark-100">{ticket.title}</div>
<span className={`${getStatusBadge(ticket.status)} flex-shrink-0`}>
{getStatusLabel(ticket.status)}
</span>
@@ -490,10 +516,20 @@ export default function Support() {
))}
</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" />
<div className="py-12 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">
<svg
className="h-8 w-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>
@@ -502,21 +538,26 @@ export default function Support() {
</div>
{/* Ticket Detail / Create Form */}
<div className="lg:col-span-2 bento-card">
<div className="bento-card lg:col-span-2">
{showCreateForm ? (
<div>
<h2 className="text-lg font-semibold text-dark-100 mb-6">{t('support.createTicket')}</h2>
<h2 className="mb-6 text-lg font-semibold text-dark-100">
{t('support.createTicket')}
</h2>
<form
onSubmit={(e) => {
e.preventDefault()
setRateLimitError(null)
e.preventDefault();
setRateLimitError(null);
// Rate limit: max 3 tickets per 60 seconds
if (!checkRateLimit(RATE_LIMIT_KEYS.TICKET_CREATE, 3, 60000)) {
const resetTime = getRateLimitResetTime(RATE_LIMIT_KEYS.TICKET_CREATE)
setRateLimitError(t('support.tooManyRequests', { seconds: resetTime }) || `Слишком много запросов. Подождите ${resetTime} сек.`)
return
const resetTime = getRateLimitResetTime(RATE_LIMIT_KEYS.TICKET_CREATE);
setRateLimitError(
t('support.tooManyRequests', { seconds: resetTime }) ||
`Слишком много запросов. Подождите ${resetTime} сек.`,
);
return;
}
createMutation.mutate()
createMutation.mutate();
}}
className="space-y-4"
>
@@ -554,9 +595,9 @@ export default function Support() {
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 = ''
const file = e.target.files?.[0];
if (file) handleFileSelect(file, setCreateAttachment);
e.target.value = '';
}}
/>
{createAttachment ? (
@@ -568,7 +609,7 @@ export default function Support() {
<button
type="button"
onClick={() => createFileInputRef.current?.click()}
className="flex items-center gap-2 text-sm text-dark-400 hover:text-dark-200 transition-colors"
className="flex items-center gap-2 text-sm text-dark-400 transition-colors hover:text-dark-200"
>
<ImageIcon />
{t('support.attachImage')}
@@ -577,7 +618,7 @@ export default function Support() {
</div>
{rateLimitError && (
<div className="bg-error-500/10 border border-error-500/30 text-error-400 p-3 rounded-xl text-sm">
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-3 text-sm text-error-400">
{rateLimitError}
</div>
)}
@@ -585,12 +626,12 @@ export default function Support() {
<div className="flex gap-3">
<button
type="submit"
disabled={createMutation.isPending || (createAttachment?.uploading)}
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" />
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
{t('support.creating')}
</span>
) : (
@@ -603,8 +644,8 @@ export default function Support() {
<button
type="button"
onClick={() => {
setShowCreateForm(false)
setCreateAttachment(null)
setShowCreateForm(false);
setCreateAttachment(null);
}}
className="btn-secondary"
>
@@ -614,18 +655,19 @@ export default function Support() {
</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 className="flex h-full flex-col">
<div className="mb-6 flex flex-col gap-2 border-b border-dark-800/50 pb-4 sm:flex-row sm:items-start sm:justify-between">
<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">
<div className="mt-2 flex flex-wrap items-center gap-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()}
{t('support.created')}{' '}
{new Date(selectedTicket.created_at).toLocaleDateString()}
</span>
</div>
</div>
@@ -634,30 +676,30 @@ export default function Support() {
{/* 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 className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : ticketDetail?.messages ? (
<div className="flex-1 space-y-4 mb-6 max-h-96 overflow-y-auto scrollbar-hide">
<div className="scrollbar-hide mb-6 max-h-96 flex-1 space-y-4 overflow-y-auto">
{ticketDetail.messages.map((msg) => (
<div
key={msg.id}
className={`p-4 rounded-xl ${
className={`rounded-xl p-4 ${
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'
? 'ml-4 border border-accent-500/20 bg-accent-500/10'
: 'mr-4 border border-dark-700/30 bg-dark-800/50'
}`}
>
<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'}`}>
<div className="mb-2 flex items-center justify-between">
<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>
<div className="whitespace-pre-wrap text-dark-200">{msg.message_text}</div>
{/* Display media if present */}
<MessageMedia message={msg} t={t} />
</div>
@@ -669,22 +711,25 @@ export default function Support() {
{ticketDetail?.status !== 'closed' && !ticketDetail?.is_reply_blocked && (
<form
onSubmit={(e) => {
e.preventDefault()
setRateLimitError(null)
e.preventDefault();
setRateLimitError(null);
// Rate limit: max 5 replies per 30 seconds
if (!checkRateLimit(RATE_LIMIT_KEYS.TICKET_REPLY, 5, 30000)) {
const resetTime = getRateLimitResetTime(RATE_LIMIT_KEYS.TICKET_REPLY)
setRateLimitError(t('support.tooManyRequests', { seconds: resetTime }) || `Слишком много запросов. Подождите ${resetTime} сек.`)
return
const resetTime = getRateLimitResetTime(RATE_LIMIT_KEYS.TICKET_REPLY);
setRateLimitError(
t('support.tooManyRequests', { seconds: resetTime }) ||
`Слишком много запросов. Подождите ${resetTime} сек.`,
);
return;
}
replyMutation.mutate()
replyMutation.mutate();
}}
className="pt-4 border-t border-dark-800/50"
className="border-t border-dark-800/50 pt-4"
>
<div className="space-y-3">
<div className="flex gap-3">
<textarea
className="input flex-1 min-h-[80px]"
className="input min-h-[80px] flex-1"
placeholder={t('support.replyPlaceholder')}
value={replyMessage}
onChange={(e) => setReplyMessage(e.target.value)}
@@ -703,9 +748,9 @@ export default function Support() {
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 = ''
const file = e.target.files?.[0];
if (file) handleFileSelect(file, setReplyAttachment);
e.target.value = '';
}}
/>
{replyAttachment ? (
@@ -717,7 +762,7 @@ export default function Support() {
<button
type="button"
onClick={() => replyFileInputRef.current?.click()}
className="flex items-center gap-2 text-sm text-dark-400 hover:text-dark-200 transition-colors"
className="flex items-center gap-2 text-sm text-dark-400 transition-colors hover:text-dark-200"
>
<ImageIcon />
{t('support.attachImage')}
@@ -727,18 +772,22 @@ export default function Support() {
<button
type="submit"
disabled={replyMutation.isPending || !replyMessage.trim() || replyAttachment?.uploading}
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" />
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<SendIcon />
)}
</button>
</div>
{rateLimitError && (
<div className="mt-2 bg-error-500/10 border border-error-500/30 text-error-400 p-2 rounded-lg text-sm">
<div className="mt-2 rounded-lg border border-error-500/30 bg-error-500/10 p-2 text-sm text-error-400">
{rateLimitError}
</div>
)}
@@ -747,16 +796,26 @@ export default function Support() {
)}
{ticketDetail?.is_reply_blocked && (
<div className="text-center py-4 text-sm text-dark-500 border-t border-dark-800/50">
<div className="border-t border-dark-800/50 py-4 text-center text-sm text-dark-500">
{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" />
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">
<svg
className="h-8 w-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>
@@ -765,5 +824,5 @@ export default function Support() {
</div>
</div>
</div>
)
);
}

View File

@@ -1,44 +1,44 @@
import { useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuthStore } from '../store/auth'
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()
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [error, setError] = useState('');
const { loginWithTelegramWidget, isAuthenticated } = useAuthStore();
useEffect(() => {
if (isAuthenticated) {
navigate('/')
return
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')
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
setError(t('auth.telegramRequired'));
return;
}
// Parse and validate numeric fields
const parsedId = parseInt(id, 10)
const parsedAuthDate = parseInt(authDate, 10)
const parsedId = parseInt(id, 10);
const parsedAuthDate = parseInt(authDate, 10);
if (Number.isNaN(parsedId) || Number.isNaN(parsedAuthDate)) {
setError(t('auth.telegramRequired'))
return
setError(t('auth.telegramRequired'));
return;
}
try {
@@ -50,46 +50,39 @@ export default function TelegramCallback() {
photo_url: photoUrl || undefined,
auth_date: parsedAuthDate,
hash: hash,
})
navigate('/')
});
navigate('/');
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
setError(error.response?.data?.detail || t('common.error'))
const error = err as { response?: { data?: { detail?: string } } };
setError(error.response?.data?.detail || t('common.error'));
}
}
};
authenticate()
}, [searchParams, loginWithTelegramWidget, navigate, isAuthenticated, t])
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"
>
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-8">
<div className="w-full max-w-md text-center">
<div className="mb-4 text-5xl text-red-500"></div>
<h2 className="mb-2 text-lg font-semibold text-gray-900">{t('auth.loginFailed')}</h2>
<p className="mb-6 text-sm text-gray-500">{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="flex min-h-screen 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 className="border-primary-600 mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-b-2"></div>
<h2 className="text-lg font-semibold text-gray-900">{t('auth.authenticating')}</h2>
<p className="mt-2 text-sm text-gray-500">{t('common.loading')}</p>
</div>
</div>
)
);
}

View File

@@ -1,180 +1,198 @@
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'
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';
// Validate redirect URL to prevent open redirect attacks
const getSafeRedirectUrl = (url: string | null): string => {
if (!url) return '/'
if (!url) return '/';
// Only allow relative paths starting with /
// Block protocol-relative URLs (//evil.com) and absolute URLs
if (!url.startsWith('/') || url.startsWith('//')) {
return '/'
return '/';
}
// Additional check for encoded characters that could bypass validation
try {
const decoded = decodeURIComponent(url)
const decoded = decodeURIComponent(url);
if (!decoded.startsWith('/') || decoded.startsWith('//') || decoded.includes('://')) {
return '/'
return '/';
}
} catch {
return '/'
return '/';
}
return url
}
return url;
};
const MAX_RETRY_ATTEMPTS = 3
const RETRY_COUNT_KEY = 'telegram_redirect_retry_count'
const MAX_RETRY_ATTEMPTS = 3;
const RETRY_COUNT_KEY = 'telegram_redirect_retry_count';
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('')
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('');
const [retryCount, setRetryCount] = useState(() => {
const stored = sessionStorage.getItem(RETRY_COUNT_KEY)
return stored ? parseInt(stored, 10) : 0
})
const stored = sessionStorage.getItem(RETRY_COUNT_KEY);
return stored ? parseInt(stored, 10) : 0;
});
// 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
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 (validated)
const redirectTo = getSafeRedirectUrl(searchParams.get('redirect'))
const redirectTo = getSafeRedirectUrl(searchParams.get('redirect'));
useEffect(() => {
// If already authenticated, redirect immediately
if (isAuthenticated && !authLoading) {
setStatus('success')
setTimeout(() => navigate(redirectTo), 500)
return
setStatus('success');
setTimeout(() => navigate(redirectTo), 500);
return;
}
const initTelegram = async () => {
// Check if running in Telegram WebApp
const tg = window.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
setStatus('not-telegram');
setTimeout(() => navigate('/login'), 2000);
return;
}
// Initialize Telegram WebApp
tg.ready()
tg.expand()
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')
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')
await loginWithTelegram(tg.initData);
setStatus('success');
// Small delay for nice UX
setTimeout(() => {
navigate(redirectTo)
}, 800)
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')
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])
setTimeout(initTelegram, 300);
}, [loginWithTelegram, navigate, isAuthenticated, authLoading, redirectTo, t]);
// Handle retry with limit to prevent infinite loops
const handleRetry = () => {
if (retryCount >= MAX_RETRY_ATTEMPTS) {
setErrorMessage('Превышено количество попыток. Попробуйте позже.')
sessionStorage.removeItem(RETRY_COUNT_KEY)
return
setErrorMessage('Превышено количество попыток. Попробуйте позже.');
sessionStorage.removeItem(RETRY_COUNT_KEY);
return;
}
const newCount = retryCount + 1
setRetryCount(newCount)
sessionStorage.setItem(RETRY_COUNT_KEY, String(newCount))
setStatus('loading')
setErrorMessage('')
window.location.reload()
}
const newCount = retryCount + 1;
setRetryCount(newCount);
sessionStorage.setItem(RETRY_COUNT_KEY, String(newCount));
setStatus('loading');
setErrorMessage('');
window.location.reload();
};
// Clear retry count on successful auth
useEffect(() => {
if (status === 'success') {
sessionStorage.removeItem(RETRY_COUNT_KEY)
sessionStorage.removeItem(RETRY_COUNT_KEY);
}
}, [status])
}, [status]);
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="flex min-h-screen 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">
<div className="relative w-full max-w-sm text-center">
{/* 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">
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center overflow-hidden rounded-2xl bg-gradient-to-br from-accent-400 to-accent-600 shadow-lg shadow-accent-500/30">
{branding?.has_custom_logo && logoUrl ? (
<img src={logoUrl} alt={appName} className="w-full h-full object-cover" />
<img src={logoUrl} alt={appName} className="h-full w-full object-cover" />
) : (
<span className="text-white font-bold text-3xl">{logoLetter}</span>
<span className="text-3xl font-bold text-white">{logoLetter}</span>
)}
</div>
<h1 className="text-2xl font-bold text-dark-50 mb-2">{appName}</h1>
<h1 className="mb-2 text-2xl font-bold text-dark-50">{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" />
<div className="border-3 mx-auto mb-4 h-10 w-10 animate-spin rounded-full border-accent-500 border-t-transparent" />
<p className="text-dark-400">{t('auth.authenticating')}</p>
<p className="text-sm text-dark-500 mt-2">{t('common.loading')}</p>
<p className="mt-2 text-sm text-dark-500">{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}>
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-success-500/20">
<svg
className="h-8 w-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>
<p className="mt-2 text-sm text-dark-500">Перенаправление...</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}>
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-error-500/20">
<svg
className="h-8 w-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>
<p className="mb-2 text-dark-200">{t('auth.loginFailed')}</p>
<p className="mb-6 text-sm text-error-400">{errorMessage}</p>
<div className="flex flex-col gap-3">
<button onClick={handleRetry} className="btn-primary w-full">
{t('auth.tryAgain')}
@@ -189,13 +207,23 @@ export default function TelegramRedirect() {
{/* 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" />
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-warning-500/20">
<svg
className="h-8 w-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">
<p className="mb-2 text-dark-200">Откройте в Telegram</p>
<p className="mb-6 text-sm text-dark-400">
Для автоматического входа откройте это приложение через бота в Telegram
</p>
<p className="text-sm text-dark-500">Перенаправление на страницу входа...</p>
@@ -204,12 +232,12 @@ export default function TelegramRedirect() {
{/* 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 className="h-5 w-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>
)
);
}

View File

@@ -1,83 +1,91 @@
import { useEffect, useState } from 'react'
import { useSearchParams, Link, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { authApi } from '../api/auth'
import { useAuthStore } from '../store/auth'
import { tokenStorage } from '../utils/token'
import LanguageSwitcher from '../components/LanguageSwitcher'
import { useEffect, useState } from 'react';
import { useSearchParams, Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { authApi } from '../api/auth';
import { useAuthStore } from '../store/auth';
import { tokenStorage } from '../utils/token';
import LanguageSwitcher from '../components/LanguageSwitcher';
export default function VerifyEmail() {
const { t } = useTranslation()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
const [error, setError] = useState('')
const { setTokens, setUser, checkAdminStatus } = useAuthStore()
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
const [error, setError] = useState('');
const { setTokens, setUser, checkAdminStatus } = useAuthStore();
useEffect(() => {
const token = searchParams.get('token')
const token = searchParams.get('token');
if (!token) {
setStatus('error')
setError(t('common.error'))
return
setStatus('error');
setError(t('common.error'));
return;
}
const verify = async () => {
try {
const response = await authApi.verifyEmail(token)
const response = await authApi.verifyEmail(token);
// Save tokens and log user in
tokenStorage.setTokens(response.access_token, response.refresh_token)
setTokens(response.access_token, response.refresh_token)
setUser(response.user)
checkAdminStatus()
setStatus('success')
tokenStorage.setTokens(response.access_token, response.refresh_token);
setTokens(response.access_token, response.refresh_token);
setUser(response.user);
checkAdminStatus();
setStatus('success');
// Redirect to dashboard after short delay
setTimeout(() => navigate('/', { replace: true }), 1500)
setTimeout(() => navigate('/', { replace: true }), 1500);
} catch (err: unknown) {
setStatus('error')
const error = err as { response?: { data?: { detail?: string } } }
setError(error.response?.data?.detail || t('emailVerification.failed'))
setStatus('error');
const error = err as { response?: { data?: { detail?: string } } };
setError(error.response?.data?.detail || t('emailVerification.failed'));
}
}
};
verify()
}, [searchParams, t, navigate, setTokens, setUser, checkAdminStatus])
verify();
}, [searchParams, t, navigate, setTokens, setUser, checkAdminStatus]);
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-8 px-4 sm:py-12">
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-8 sm:py-12">
{/* Language switcher in corner */}
<div className="fixed top-4 right-4 z-50">
<div className="fixed right-4 top-4 z-50">
<LanguageSwitcher />
</div>
<div className="max-w-md w-full text-center">
<div className="w-full max-w-md 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 className="border-primary-600 mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-b-2"></div>
<h2 className="text-lg font-semibold text-gray-900 sm:text-xl">
{t('emailVerification.verifying')}
</h2>
<p className="mt-2 text-sm text-gray-500 sm:text-base">
{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">
<div className="mb-4 text-5xl text-green-500 sm:text-6xl"></div>
<h2 className="text-lg font-semibold text-gray-900 sm:text-xl">
{t('emailVerification.success')}
</h2>
<p className="mt-2 text-sm text-gray-500 sm:text-base">
{t('emailVerification.redirecting', 'Redirecting to dashboard...')}
</p>
<div className="mt-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600 mx-auto"></div>
<div className="border-primary-600 mx-auto h-6 w-6 animate-spin rounded-full border-b-2"></div>
</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="mb-4 text-5xl text-red-500 sm:text-6xl"></div>
<h2 className="text-lg font-semibold text-gray-900 sm:text-xl">
{t('emailVerification.failed')}
</h2>
<p className="mt-2 text-sm text-gray-500 sm:text-base">{error}</p>
<div className="mt-6">
<Link to="/login" className="btn-secondary">
{t('emailVerification.goToLogin')}
@@ -87,5 +95,5 @@ export default function VerifyEmail() {
)}
</div>
</div>
)
);
}

View File

@@ -1,10 +1,10 @@
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 InsufficientBalancePrompt from '../components/InsufficientBalancePrompt'
import { useCurrency } from '../hooks/useCurrency'
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 InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
import { useCurrency } from '../hooks/useCurrency';
// Pre-calculated confetti positions (stable across re-renders)
const CONFETTI_POSITIONS = Array.from({ length: 20 }, (_, i) => ({
@@ -13,170 +13,196 @@ const CONFETTI_POSITIONS = Array.from({ length: 20 }, (_, i) => ({
top: `${(i * 23 + 3) % 90}%`,
delay: `${(i * 0.1) % 2}s`,
duration: `${1 + (i % 3) * 0.3}s`,
}))
}));
// Icons
const StarIcon = () => (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<svg className="h-5 w-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 className="h-5 w-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 className="h-5 w-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}>
<svg className="h-6 w-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 className="h-6 w-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 className="h-8 w-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 { 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 [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 webApp = window.Telegram?.WebApp;
return !!(webApp && typeof webApp.initData === 'string');
}, []);
const { data: config, isLoading, error } = useQuery({
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,
})
});
// Auto-select payment type based on availability
useEffect(() => {
if (!config) return
if (!config) return;
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
const canPayDays = daysEnabled && config.can_pay_days
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;
const canPayDays = daysEnabled && config.can_pay_days;
if (isTelegramMiniApp) {
// In Mini App: prefer days if available, Stars payment is separate button
if (canPayDays) {
setPaymentType('subscription_days')
setPaymentType('subscription_days');
}
} else {
// In Web: prefer balance (Stars converted to rubles), fallback to days
if (canPayBalance) {
setPaymentType('telegram_stars')
setPaymentType('telegram_stars');
} else if (canPayDays) {
setPaymentType('subscription_days')
setPaymentType('subscription_days');
}
}
}, [config, isTelegramMiniApp])
}, [config, isTelegramMiniApp]);
// Function to poll for new spin result after Stars payment
const pollForSpinResult = useCallback(async (signal: AbortSignal, 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))
const pollForSpinResult = useCallback(
async (signal: AbortSignal, 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));
if (signal.aborted) return null
// 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++) {
if (signal.aborted) return null
await new Promise(resolve => setTimeout(resolve, delayMs))
if (signal.aborted) return null
if (signal.aborted) return null;
// Get current history to find the latest spin ID
let historyBefore;
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
}
}
historyBefore = await wheelApi.getHistory(1, 1);
} catch {
// Continue polling on error
historyBefore = { items: [], total: 0 };
}
}
const lastSpinIdBefore = historyBefore.items.length > 0 ? historyBefore.items[0].id : 0;
// Timeout - couldn't find new spin
return null
}, [t])
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (signal.aborted) return null;
await new Promise((resolve) => setTimeout(resolve, delayMs));
if (signal.aborted) return null;
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 pollingAbortRef = useRef<AbortController | null>(null)
const pendingStarsResultRef = useRef<SpinResult | null>(null);
const isStarsSpinRef = useRef(false);
const pollingAbortRef = useRef<AbortController | null>(null);
// Cleanup polling on unmount
useEffect(() => {
return () => {
if (pollingAbortRef.current) {
pollingAbortRef.current.abort()
pollingAbortRef.current.abort();
}
}
}, [])
};
}, []);
const starsInvoiceMutation = useMutation({
mutationFn: wheelApi.createStarsInvoice,
@@ -185,35 +211,55 @@ export default function Wheel() {
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
isStarsSpinRef.current = true;
pendingStarsResultRef.current = null;
// Cancel any existing polling
if (pollingAbortRef.current) {
pollingAbortRef.current.abort()
pollingAbortRef.current.abort();
}
pollingAbortRef.current = new AbortController()
pollingAbortRef.current = new AbortController();
// Payment done - reset paying state immediately
setIsPayingStars(false)
setIsPayingStars(false);
// Start spinning animation (5 seconds duration in FortuneWheel)
setIsSpinning(true)
setTargetRotation(360 * 5 + Math.random() * 360)
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
const abortSignal = pollingAbortRef.current.signal
pollForSpinResult(abortSignal).then((result) => {
if (abortSignal.aborted) return
const abortSignal = pollingAbortRef.current.signal;
pollForSpinResult(abortSignal)
.then((result) => {
if (abortSignal.aborted) return;
queryClient.invalidateQueries({ queryKey: ['wheel-config'] })
queryClient.invalidateQueries({ queryKey: ['wheel-history'] })
queryClient.invalidateQueries({ queryKey: ['wheel-config'] });
queryClient.invalidateQueries({ queryKey: ['wheel-history'] });
if (result) {
pendingStarsResultRef.current = result
} else {
// Fallback: couldn't get result
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(() => {
if (abortSignal.aborted) return;
// Error polling, show generic success
pendingStarsResultRef.current = {
success: true,
prize_id: null,
@@ -226,28 +272,10 @@ export default function Wheel() {
message: t('wheel.starsPaymentSuccessCheckHistory'),
promocode: null,
error: null,
}
}
}).catch(() => {
if (abortSignal.aborted) return
// 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)
setIsPayingStars(false);
setSpinResult({
success: false,
prize_id: null,
@@ -260,15 +288,15 @@ export default function Wheel() {
message: t('wheel.starsPaymentFailed'),
promocode: null,
error: 'payment_failed',
})
setShowResultModal(true)
});
setShowResultModal(true);
} else {
setIsPayingStars(false)
setIsPayingStars(false);
}
})
});
} else {
// openInvoice not available - show error
setIsPayingStars(false)
setIsPayingStars(false);
setSpinResult({
success: false,
prize_id: null,
@@ -281,12 +309,12 @@ export default function Wheel() {
message: t('wheel.errors.starsNotAvailable'),
promocode: null,
error: 'stars_not_available',
})
setShowResultModal(true)
});
setShowResultModal(true);
}
},
onError: () => {
setIsPayingStars(false)
setIsPayingStars(false);
setSpinResult({
success: false,
prize_id: null,
@@ -299,30 +327,30 @@ export default function Wheel() {
message: t('wheel.errors.networkError'),
promocode: null,
error: 'network_error',
})
setShowResultModal(true)
});
setShowResultModal(true);
},
})
});
const handleDirectStarsPay = () => {
setIsPayingStars(true)
starsInvoiceMutation.mutate()
}
setIsPayingStars(true);
starsInvoiceMutation.mutate();
};
const spinMutation = useMutation({
mutationFn: () => wheelApi.spin(paymentType),
onSuccess: (result) => {
if (result.success) {
setTargetRotation(result.rotation_degrees)
setSpinResult(result)
setTargetRotation(result.rotation_degrees);
setSpinResult(result);
} else {
setIsSpinning(false)
setSpinResult(result)
setShowResultModal(true)
setIsSpinning(false);
setSpinResult(result);
setShowResultModal(true);
}
},
onError: () => {
setIsSpinning(false)
setIsSpinning(false);
setSpinResult({
success: false,
message: t('wheel.errors.networkError'),
@@ -335,28 +363,28 @@ export default function Wheel() {
color: '',
rotation_degrees: 0,
promocode: null,
})
setShowResultModal(true)
});
setShowResultModal(true);
},
})
});
const handleSpin = () => {
if (!config?.can_spin || isSpinning) return
setIsSpinning(true)
spinMutation.mutate()
}
if (!config?.can_spin || isSpinning) return;
setIsSpinning(true);
spinMutation.mutate();
};
const handleSpinComplete = useCallback(() => {
setIsSpinning(false)
setIsSpinning(false);
// Check if this was a Stars payment spin
if (isStarsSpinRef.current) {
isStarsSpinRef.current = false
isStarsSpinRef.current = false;
// Use the pending result from polling, or show a fallback
if (pendingStarsResultRef.current) {
setSpinResult(pendingStarsResultRef.current)
pendingStarsResultRef.current = null
setSpinResult(pendingStarsResultRef.current);
pendingStarsResultRef.current = null;
} else {
// Polling still in progress or failed - show fallback
setSpinResult({
@@ -371,86 +399,84 @@ export default function Wheel() {
message: t('wheel.starsPaymentSuccessCheckHistory'),
promocode: null,
error: null,
})
});
}
}
setShowResultModal(true)
queryClient.invalidateQueries({ queryKey: ['wheel-config'] })
queryClient.invalidateQueries({ queryKey: ['wheel-history'] })
}, [queryClient, t])
setShowResultModal(true);
queryClient.invalidateQueries({ queryKey: ['wheel-config'] });
queryClient.invalidateQueries({ queryKey: ['wheel-history'] });
}, [queryClient, t]);
const closeResultModal = () => {
setShowResultModal(false)
setSpinResult(null)
setTargetRotation(null)
}
setShowResultModal(false);
setSpinResult(null);
setTargetRotation(null);
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="flex min-h-[60vh] items-center justify-center">
<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="h-16 w-16 animate-spin rounded-full border-4 border-purple-500/30 border-t-purple-500" />
<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">
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-4">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-red-500/10">
<span className="text-4xl">😔</span>
</div>
<p className="text-dark-400 text-lg">{t('wheel.errors.loadFailed')}</p>
<p className="text-lg text-dark-400">{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">
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-6">
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-dark-800">
<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>
<h1 className="mb-2 text-2xl font-bold text-dark-100">{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
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;
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="absolute right-0 top-0 h-64 w-64 -translate-y-1/2 translate-x-1/2 rounded-full bg-purple-500/10 blur-3xl" />
<div className="absolute bottom-0 left-0 h-48 w-48 -translate-x-1/2 translate-y-1/2 rounded-full bg-blue-500/10 blur-3xl" />
<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">
<div className="mb-2 flex items-center gap-3">
<div className="rounded-xl bg-gradient-to-br from-purple-500 to-indigo-600 p-2 shadow-lg shadow-purple-500/25">
<TrophyIcon />
</div>
<h1 className="text-2xl sm:text-3xl font-bold text-white">
{t('wheel.title')}
</h1>
<h1 className="text-2xl font-bold text-white sm:text-3xl">{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">
<span className="rounded-full bg-white/10 px-3 py-1 font-bold text-purple-300">
{Math.max(0, config.daily_limit - config.user_spins_today)} / {config.daily_limit}
</span>
</div>
@@ -459,7 +485,7 @@ export default function Wheel() {
<button
onClick={() => setShowHistory(!showHistory)}
className={`p-3 rounded-xl transition-all ${
className={`rounded-xl p-3 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'
@@ -471,16 +497,17 @@ export default function Wheel() {
</div>
{/* Main Content Grid */}
<div className="grid lg:grid-cols-[1fr,320px] gap-6">
<div className="grid gap-6 lg:grid-cols-[1fr,320px]">
{/* 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">
<div className="relative rounded-2xl border border-dark-700/50 bg-gradient-to-b from-dark-800/80 to-dark-900/80 p-6 backdrop-blur-sm sm:p-8">
{/* Background glow */}
<div
className="absolute inset-0 rounded-2xl opacity-50 pointer-events-none"
className="pointer-events-none absolute inset-0 rounded-2xl opacity-50"
style={{
background: 'radial-gradient(circle at center, rgba(139, 92, 246, 0.15) 0%, transparent 70%)',
background:
'radial-gradient(circle at center, rgba(139, 92, 246, 0.15) 0%, transparent 70%)',
}}
/>
@@ -504,11 +531,17 @@ export default function Wheel() {
{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"
disabled={
isSpinning ||
isPayingStars ||
(config.daily_limit > 0 && config.user_spins_today >= config.daily_limit)
}
className="flex w-full items-center justify-center gap-3 rounded-xl bg-gradient-to-r from-yellow-500 to-orange-500 p-4 font-bold text-black shadow-lg shadow-yellow-500/25 transition-all hover:shadow-yellow-500/40 disabled:cursor-not-allowed disabled:opacity-50"
>
<StarIcon />
{isPayingStars ? t('wheel.processingPayment') : `${t('wheel.payWithStars')} (${config.spin_cost_stars} ⭐)`}
{isPayingStars
? t('wheel.processingPayment')
: `${t('wheel.payWithStars')} (${config.spin_cost_stars} ⭐)`}
</button>
)}
@@ -517,17 +550,18 @@ export default function Wheel() {
<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 ${
className={`flex w-full items-center justify-center gap-3 rounded-xl border-2 p-4 transition-all ${
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'
? 'cursor-not-allowed border-dark-700 text-dark-500 opacity-50'
: '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')})
{t('wheel.payWithDays')} ({config.spin_cost_days}{' '}
{config.spin_cost_days === 1 ? t('wheel.day') : t('wheel.days')})
</span>
</button>
)}
@@ -540,15 +574,17 @@ export default function Wheel() {
<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 ${
className={`flex flex-1 flex-col items-center justify-center gap-1 rounded-xl border-2 p-4 transition-all ${
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'
? 'cursor-not-allowed border-dark-700 text-dark-500 opacity-50'
: '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="font-semibold">
{formatAmount(config.required_balance_kopeks / 100)} {currencySymbol}
</span>
<span className="text-xs opacity-70">({config.spin_cost_stars} )</span>
</button>
)}
@@ -557,17 +593,18 @@ export default function Wheel() {
<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 ${
className={`flex flex-1 items-center justify-center gap-3 rounded-xl border-2 p-4 transition-all ${
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'
? 'cursor-not-allowed border-dark-700 text-dark-500 opacity-50'
: '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')}
{config.spin_cost_days}{' '}
{config.spin_cost_days === 1 ? t('wheel.day') : t('wheel.days')}
</span>
</button>
)}
@@ -579,25 +616,34 @@ export default function Wheel() {
{(!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]'
disabled={
!config.can_spin ||
isSpinning ||
isPayingStars ||
(isTelegramMiniApp && !canPayDays)
}
className={`group relative w-full overflow-hidden rounded-2xl py-5 text-xl font-bold transition-all ${
!config.can_spin ||
isSpinning ||
isPayingStars ||
(isTelegramMiniApp && !canPayDays)
? 'cursor-not-allowed bg-dark-700 text-dark-500'
: 'bg-gradient-to-r from-purple-600 via-indigo-600 to-purple-600 text-white shadow-xl shadow-purple-500/30 hover:scale-[1.02] hover:shadow-purple-500/50 active:scale-[0.98]'
}`}
style={{
backgroundSize: '200% 100%',
animation: !isSpinning && config.can_spin ? 'wheel-shimmer 3s linear infinite' : 'none',
animation:
!isSpinning && config.can_spin ? 'wheel-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" />
<div className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/20 to-transparent transition-transform duration-1000 group-hover:translate-x-full" />
)}
{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" />
<div className="border-3 h-6 w-6 animate-spin rounded-full border-white/30 border-t-white" />
{t('wheel.spinning')}
</span>
) : (
@@ -613,15 +659,17 @@ export default function Wheel() {
{!config.can_spin && !isSpinning && (
<>
{config.can_spin_reason === 'daily_limit_reached' ? (
<div className="text-center p-4 rounded-xl bg-dark-800/50 border border-dark-700">
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4 text-center">
<p className="text-dark-400">{t('wheel.errors.dailyLimitReached')}</p>
</div>
) : config.can_spin_reason === 'insufficient_balance' ? (
<InsufficientBalancePrompt
missingAmountKopeks={config.required_balance_kopeks - config.user_balance_kopeks}
missingAmountKopeks={
config.required_balance_kopeks - config.user_balance_kopeks
}
/>
) : (
<div className="text-center p-4 rounded-xl bg-dark-800/50 border border-dark-700">
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4 text-center">
<p className="text-dark-400">{t('wheel.errors.cannotSpin')}</p>
</div>
)}
@@ -633,16 +681,16 @@ export default function Wheel() {
{/* 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">
<div className="h-fit lg:sticky lg:top-24">
<div className="overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-800/80 backdrop-blur-sm">
<div className="flex items-center justify-between border-b border-dark-700 p-4">
<h3 className="flex items-center gap-2 font-semibold text-dark-100">
<HistoryIcon />
{t('wheel.recentSpins')}
</h3>
<button
onClick={() => setShowHistory(false)}
className="lg:hidden p-1 text-dark-400 hover:text-dark-200"
className="p-1 text-dark-400 hover:text-dark-200 lg:hidden"
>
<CloseIcon />
</button>
@@ -658,21 +706,22 @@ export default function Wheel() {
}`}
>
<div
className="w-12 h-12 rounded-xl flex items-center justify-center text-2xl"
className="flex h-12 w-12 items-center justify-center rounded-xl 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">
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-dark-100">
{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'
<div className="whitespace-nowrap text-xs text-dark-400">
-
{item.payment_type === 'telegram_stars'
? `${item.payment_amount}`
: `${item.payment_amount}${t('wheel.days').charAt(0)}`}
</div>
@@ -681,7 +730,7 @@ export default function Wheel() {
</div>
) : (
<div className="p-8 text-center text-dark-500">
<div className="text-4xl mb-2">🎰</div>
<div className="mb-2 text-4xl">🎰</div>
{t('wheel.noHistory')}
</div>
)}
@@ -692,9 +741,9 @@ export default function Wheel() {
{/* Result Modal */}
{showResultModal && spinResult && (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-fade-in">
<div className="fixed inset-0 z-[60] flex animate-fade-in items-center justify-center bg-black/80 p-4 backdrop-blur-md">
<div
className={`relative w-full max-w-md rounded-3xl p-8 text-center overflow-hidden ${
className={`relative w-full max-w-md overflow-hidden rounded-3xl p-8 text-center ${
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'
@@ -703,14 +752,14 @@ export default function Wheel() {
{/* 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" />
<div className="absolute left-0 top-0 h-32 w-32 rounded-full bg-purple-500/20 blur-3xl" />
<div className="absolute bottom-0 right-0 h-32 w-32 rounded-full bg-indigo-500/20 blur-3xl" />
{/* Confetti effect - using pre-calculated positions */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
{CONFETTI_POSITIONS.map((pos, i) => (
<div
key={i}
className="absolute w-2 h-2 rounded-full animate-bounce"
className="absolute h-2 w-2 animate-bounce rounded-full"
style={{
background: pos.color,
left: pos.left,
@@ -727,7 +776,7 @@ export default function Wheel() {
{/* 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"
className="absolute right-4 top-4 rounded-lg p-2 text-dark-400 transition-colors hover:bg-white/5 hover:text-dark-200"
>
<CloseIcon />
</button>
@@ -736,7 +785,7 @@ export default function Wheel() {
<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"
className="mx-auto flex h-28 w-28 items-center justify-center rounded-full text-6xl shadow-2xl"
style={{
background: spinResult.success
? `linear-gradient(135deg, ${spinResult.color || '#8B5CF6'}40, ${spinResult.color || '#8B5CF6'}20)`
@@ -746,7 +795,7 @@ export default function Wheel() {
: 'none',
}}
>
{spinResult.success ? (spinResult.emoji || '🎉') : '😔'}
{spinResult.success ? spinResult.emoji || '🎉' : '😔'}
</div>
{/* Title */}
@@ -763,9 +812,9 @@ export default function Wheel() {
{/* 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">
<div className="rounded-2xl border border-purple-500/30 bg-gradient-to-r from-purple-500/20 via-indigo-500/20 to-purple-500/20 p-5">
<p className="mb-3 text-sm text-purple-300">{t('wheel.yourPromoCode')}</p>
<p className="select-all font-mono text-2xl font-bold tracking-wider text-white">
{spinResult.promocode}
</p>
</div>
@@ -774,7 +823,7 @@ export default function Wheel() {
{/* 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"
className="w-full rounded-xl bg-white/10 py-4 font-semibold text-white transition-all hover:bg-white/20"
>
{t('wheel.close')}
</button>
@@ -782,7 +831,6 @@ export default function Wheel() {
</div>
</div>
)}
</div>
)
);
}