From 905e997ceebd5c08dead694f6879b9c57985b295 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 17 Jan 2026 04:37:10 +0300 Subject: [PATCH] Add files via upload --- src/pages/AdminPanel.tsx | 16 + src/pages/AdminPromocodes.tsx | 876 ++++++++++++++++++++++++++++++++++ 2 files changed, 892 insertions(+) create mode 100644 src/pages/AdminPromocodes.tsx diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 22bae59..6ce47cc 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -57,6 +57,12 @@ const BroadcastIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( ) +const PromocodeIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( + + + +) + const ChevronRightIcon = () => ( @@ -196,6 +202,16 @@ export default function AdminPanel() { bgColor: 'bg-orange-500/20', textColor: 'text-orange-400' }, + { + to: '/admin/promocodes', + icon: , + mobileIcon: , + title: t('admin.nav.promocodes', 'Промокоды'), + description: t('admin.panel.promocodesDesc', 'Управление промокодами'), + color: 'violet', + bgColor: 'bg-violet-500/20', + textColor: 'text-violet-400' + }, ] return ( diff --git a/src/pages/AdminPromocodes.tsx b/src/pages/AdminPromocodes.tsx new file mode 100644 index 0000000..184b417 --- /dev/null +++ b/src/pages/AdminPromocodes.tsx @@ -0,0 +1,876 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { + promocodesApi, + PromoCode, + PromoCodeDetail, + PromoCodeType, + PromoCodeCreateRequest, + PromoCodeUpdateRequest, + PromoGroup, + PromoGroupCreateRequest, + PromoGroupUpdateRequest, +} from '../api/promocodes' + +// Icons +const TicketIcon = () => ( + + + +) + +const PlusIcon = () => ( + + + +) + +const EditIcon = () => ( + + + +) + +const TrashIcon = () => ( + + + +) + +const CheckIcon = () => ( + + + +) + +const XIcon = () => ( + + + +) + +const CopyIcon = () => ( + + + +) + +const UsersIcon = () => ( + + + +) + +// Helper functions +const getTypeLabel = (type: PromoCodeType): string => { + const labels: Record = { + balance: 'Баланс', + subscription_days: 'Дни подписки', + trial_subscription: 'Тестовая подписка', + promo_group: 'Группа скидок', + } + return labels[type] || type +} + +const getTypeColor = (type: PromoCodeType): string => { + const colors: Record = { + balance: 'bg-emerald-500/20 text-emerald-400', + subscription_days: 'bg-blue-500/20 text-blue-400', + trial_subscription: 'bg-purple-500/20 text-purple-400', + promo_group: 'bg-amber-500/20 text-amber-400', + } + return colors[type] || 'bg-dark-600 text-dark-300' +} + +const formatDate = (date: string | null): string => { + if (!date) return '-' + return new Date(date).toLocaleDateString('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }) +} + +// Promocode Modal +interface PromocodeModalProps { + promocode?: PromoCodeDetail | null + promoGroups: PromoGroup[] + onSave: (data: PromoCodeCreateRequest | PromoCodeUpdateRequest) => void + onClose: () => void + isLoading?: boolean +} + +function PromocodeModal({ promocode, promoGroups, onSave, onClose, isLoading }: PromocodeModalProps) { + const isEdit = !!promocode + + const [code, setCode] = useState(promocode?.code || '') + const [type, setType] = useState(promocode?.type || 'balance') + const [balanceBonusRubles, setBalanceBonusRubles] = useState(promocode?.balance_bonus_rubles || 0) + const [subscriptionDays, setSubscriptionDays] = useState(promocode?.subscription_days || 0) + const [maxUses, setMaxUses] = useState(promocode?.max_uses || 1) + const [isActive, setIsActive] = useState(promocode?.is_active ?? true) + const [firstPurchaseOnly, setFirstPurchaseOnly] = useState(promocode?.first_purchase_only || false) + const [validUntil, setValidUntil] = useState( + promocode?.valid_until ? promocode.valid_until.split('T')[0] : '' + ) + const [promoGroupId, setPromoGroupId] = useState(promocode?.promo_group_id || null) + + const handleSubmit = () => { + const data: PromoCodeCreateRequest | PromoCodeUpdateRequest = { + code: code.trim().toUpperCase(), + type, + balance_bonus_kopeks: Math.round(balanceBonusRubles * 100), + subscription_days: subscriptionDays, + max_uses: maxUses, + is_active: isActive, + first_purchase_only: firstPurchaseOnly, + valid_until: validUntil ? new Date(validUntil).toISOString() : null, + promo_group_id: type === 'promo_group' ? promoGroupId : null, + } + onSave(data) + } + + const isValid = () => { + if (!code.trim()) return false + if (type === 'balance' && balanceBonusRubles <= 0) return false + if ((type === 'subscription_days' || type === 'trial_subscription') && subscriptionDays <= 0) return false + if (type === 'promo_group' && !promoGroupId) return false + return true + } + + return ( +
+
+ {/* Header */} +
+

+ {isEdit ? 'Редактирование промокода' : 'Новый промокод'} +

+ +
+ + {/* Content */} +
+ {/* Code */} +
+ + setCode(e.target.value.toUpperCase())} + 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 uppercase" + placeholder="SUMMER2025" + /> +
+ + {/* Type */} +
+ + +
+ + {/* Type-specific fields */} + {type === 'balance' && ( +
+ +
+ setBalanceBonusRubles(Math.max(0, parseFloat(e.target.value) || 0))} + className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" + min={0} + step={1} + /> + руб. +
+
+ )} + + {(type === 'subscription_days' || type === 'trial_subscription') && ( +
+ +
+ setSubscriptionDays(Math.max(0, parseInt(e.target.value) || 0))} + className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" + min={1} + /> + дней +
+
+ )} + + {type === 'promo_group' && ( +
+ + +
+ )} + + {/* Max Uses */} +
+ +
+ setMaxUses(Math.max(0, parseInt(e.target.value) || 0))} + className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" + min={0} + /> + 0 = безлимит +
+
+ + {/* Valid Until */} +
+ + setValidUntil(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" + /> +

Оставьте пустым для бессрочного промокода

+
+ + {/* Options */} +
+ + + +
+
+ + {/* Footer */} +
+ + +
+
+
+ ) +} + +// PromoGroup Modal +interface PromoGroupModalProps { + group?: PromoGroup | null + onSave: (data: PromoGroupCreateRequest | PromoGroupUpdateRequest) => void + onClose: () => void + isLoading?: boolean +} + +function PromoGroupModal({ group, onSave, onClose, isLoading }: PromoGroupModalProps) { + const isEdit = !!group + + const [name, setName] = useState(group?.name || '') + const [serverDiscount, setServerDiscount] = useState(group?.server_discount_percent || 0) + const [trafficDiscount, setTrafficDiscount] = useState(group?.traffic_discount_percent || 0) + const [deviceDiscount, setDeviceDiscount] = useState(group?.device_discount_percent || 0) + const [applyToAddons, setApplyToAddons] = useState(group?.apply_discounts_to_addons ?? true) + const [autoAssignSpent, setAutoAssignSpent] = useState( + group?.auto_assign_total_spent_kopeks ? group.auto_assign_total_spent_kopeks / 100 : 0 + ) + + const handleSubmit = () => { + const data: PromoGroupCreateRequest | PromoGroupUpdateRequest = { + name, + server_discount_percent: serverDiscount, + traffic_discount_percent: trafficDiscount, + device_discount_percent: deviceDiscount, + apply_discounts_to_addons: applyToAddons, + auto_assign_total_spent_kopeks: autoAssignSpent > 0 ? Math.round(autoAssignSpent * 100) : null, + } + onSave(data) + } + + return ( +
+
+ {/* Header */} +
+

+ {isEdit ? 'Редактирование группы' : 'Новая группа скидок'} +

+ +
+ + {/* Content */} +
+ {/* Name */} +
+ + setName(e.target.value)} + className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" + placeholder="VIP клиенты" + /> +
+ + {/* Discounts */} +
+

Скидки

+ +
+ На серверы: + setServerDiscount(Math.min(100, Math.max(0, parseInt(e.target.value) || 0)))} + className="w-20 px-2 py-1 bg-dark-600 border border-dark-500 rounded text-dark-100 focus:outline-none focus:border-accent-500" + min={0} + max={100} + /> + % +
+ +
+ На трафик: + setTrafficDiscount(Math.min(100, Math.max(0, parseInt(e.target.value) || 0)))} + className="w-20 px-2 py-1 bg-dark-600 border border-dark-500 rounded text-dark-100 focus:outline-none focus:border-accent-500" + min={0} + max={100} + /> + % +
+ +
+ На устройства: + setDeviceDiscount(Math.min(100, Math.max(0, parseInt(e.target.value) || 0)))} + className="w-20 px-2 py-1 bg-dark-600 border border-dark-500 rounded text-dark-100 focus:outline-none focus:border-accent-500" + min={0} + max={100} + /> + % +
+
+ + {/* Auto-assign */} +
+ +
+ setAutoAssignSpent(Math.max(0, parseFloat(e.target.value) || 0))} + className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" + min={0} + /> + руб. +
+

0 = не назначать автоматически

+
+ + {/* Apply to addons */} + +
+ + {/* Footer */} +
+ + +
+
+
+ ) +} + +export default function AdminPromocodes() { + const { t } = useTranslation() + const queryClient = useQueryClient() + + const [activeTab, setActiveTab] = useState<'promocodes' | 'groups'>('promocodes') + const [showPromocodeModal, setShowPromocodeModal] = useState(false) + const [showGroupModal, setShowGroupModal] = useState(false) + const [editingPromocode, setEditingPromocode] = useState(null) + const [editingGroup, setEditingGroup] = useState(null) + const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'promocode' | 'group'; id: number } | null>(null) + const [copiedCode, setCopiedCode] = useState(null) + + // Queries + const { data: promocodesData, isLoading: promocodesLoading } = useQuery({ + queryKey: ['admin-promocodes'], + queryFn: () => promocodesApi.getPromocodes({ limit: 100 }), + }) + + const { data: groupsData, isLoading: groupsLoading } = useQuery({ + queryKey: ['admin-promo-groups'], + queryFn: () => promocodesApi.getPromoGroups({ limit: 100 }), + }) + + // Promocode Mutations + const createPromocodeMutation = useMutation({ + mutationFn: promocodesApi.createPromocode, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-promocodes'] }) + setShowPromocodeModal(false) + }, + }) + + const updatePromocodeMutation = useMutation({ + mutationFn: ({ id, data }: { id: number; data: PromoCodeUpdateRequest }) => + promocodesApi.updatePromocode(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-promocodes'] }) + setShowPromocodeModal(false) + setEditingPromocode(null) + }, + }) + + const deletePromocodeMutation = useMutation({ + mutationFn: promocodesApi.deletePromocode, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-promocodes'] }) + setDeleteConfirm(null) + }, + }) + + // PromoGroup Mutations + const createGroupMutation = useMutation({ + mutationFn: promocodesApi.createPromoGroup, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-promo-groups'] }) + setShowGroupModal(false) + }, + }) + + const updateGroupMutation = useMutation({ + mutationFn: ({ id, data }: { id: number; data: PromoGroupUpdateRequest }) => + promocodesApi.updatePromoGroup(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-promo-groups'] }) + setShowGroupModal(false) + setEditingGroup(null) + }, + }) + + const deleteGroupMutation = useMutation({ + mutationFn: promocodesApi.deletePromoGroup, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-promo-groups'] }) + setDeleteConfirm(null) + }, + }) + + const handleEditPromocode = async (id: number) => { + try { + const detail = await promocodesApi.getPromocode(id) + setEditingPromocode(detail) + setShowPromocodeModal(true) + } catch (error) { + console.error('Failed to load promocode:', error) + } + } + + const handleSavePromocode = (data: PromoCodeCreateRequest | PromoCodeUpdateRequest) => { + if (editingPromocode) { + updatePromocodeMutation.mutate({ id: editingPromocode.id, data }) + } else { + createPromocodeMutation.mutate(data as PromoCodeCreateRequest) + } + } + + const handleSaveGroup = (data: PromoGroupCreateRequest | PromoGroupUpdateRequest) => { + if (editingGroup) { + updateGroupMutation.mutate({ id: editingGroup.id, data }) + } else { + createGroupMutation.mutate(data as PromoGroupCreateRequest) + } + } + + const handleCopyCode = (code: string) => { + navigator.clipboard.writeText(code) + setCopiedCode(code) + setTimeout(() => setCopiedCode(null), 2000) + } + + const handleDelete = () => { + if (!deleteConfirm) return + if (deleteConfirm.type === 'promocode') { + deletePromocodeMutation.mutate(deleteConfirm.id) + } else { + deleteGroupMutation.mutate(deleteConfirm.id) + } + } + + const promocodes = promocodesData?.items || [] + const groups = groupsData?.items || [] + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Промокоды

+

Управление промокодами и группами скидок

+
+
+ +
+ + {/* Tabs */} +
+ + +
+ + {/* Promocodes List */} + {activeTab === 'promocodes' && ( + <> + {promocodesLoading ? ( +
+
+
+ ) : promocodes.length === 0 ? ( +
+

Нет промокодов

+
+ ) : ( +
+ {promocodes.map((promo: PromoCode) => ( +
+
+
+
+ + + {getTypeLabel(promo.type)} + + {!promo.is_active && ( + + Неактивен + + )} + {promo.first_purchase_only && ( + + Первая покупка + + )} +
+
+ {promo.type === 'balance' && ( + +{promo.balance_bonus_rubles} руб. + )} + {(promo.type === 'subscription_days' || promo.type === 'trial_subscription') && ( + +{promo.subscription_days} дней + )} + + Использовано: {promo.current_uses}/{promo.max_uses === 0 ? '∞' : promo.max_uses} + + {promo.valid_until && ( + До: {formatDate(promo.valid_until)} + )} +
+
+ +
+ + +
+
+
+ ))} +
+ )} + + )} + + {/* Groups List */} + {activeTab === 'groups' && ( + <> + {groupsLoading ? ( +
+
+
+ ) : groups.length === 0 ? ( +
+

Нет групп скидок

+
+ ) : ( +
+ {groups.map((group: PromoGroup) => ( +
+
+
+
+

{group.name}

+ {group.is_default && ( + + По умолчанию + + )} +
+
+ {group.server_discount_percent > 0 && ( + Серверы: -{group.server_discount_percent}% + )} + {group.traffic_discount_percent > 0 && ( + Трафик: -{group.traffic_discount_percent}% + )} + {group.device_discount_percent > 0 && ( + Устройства: -{group.device_discount_percent}% + )} + + + {group.members_count} участников + +
+
+ +
+ + +
+
+
+ ))} +
+ )} + + )} + + {/* Promocode Modal */} + {showPromocodeModal && ( + { + setShowPromocodeModal(false) + setEditingPromocode(null) + }} + isLoading={createPromocodeMutation.isPending || updatePromocodeMutation.isPending} + /> + )} + + {/* PromoGroup Modal */} + {showGroupModal && ( + { + setShowGroupModal(false) + setEditingGroup(null) + }} + isLoading={createGroupMutation.isPending || updateGroupMutation.isPending} + /> + )} + + {/* Delete Confirmation */} + {deleteConfirm && ( +
+
+

+ {deleteConfirm.type === 'promocode' ? 'Удалить промокод?' : 'Удалить группу?'} +

+

+ {deleteConfirm.type === 'promocode' + ? 'Промокод будет удален безвозвратно.' + : 'Группа будет удалена. Пользователи потеряют скидки этой группы.' + } +

+
+ + +
+
+
+ )} +
+ ) +}