mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
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:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user