diff --git a/frontend/src/pages/admin/AdminLayout.tsx b/frontend/src/pages/admin/AdminLayout.tsx
index 23e6b16..17f0ac7 100644
--- a/frontend/src/pages/admin/AdminLayout.tsx
+++ b/frontend/src/pages/admin/AdminLayout.tsx
@@ -12,13 +12,15 @@ import {
Shield,
MessageCircle,
Sparkles,
- Lock
+ Lock,
+ Gift
} from 'lucide-react'
const navItems = [
{ to: '/admin', icon: LayoutDashboard, label: 'Дашборд', end: true },
{ to: '/admin/users', icon: Users, label: 'Пользователи' },
{ to: '/admin/marathons', icon: Trophy, label: 'Марафоны' },
+ { to: '/admin/promo', icon: Gift, label: 'Промокоды' },
{ to: '/admin/logs', icon: ScrollText, label: 'Логи' },
{ to: '/admin/broadcast', icon: Send, label: 'Рассылка' },
{ to: '/admin/content', icon: FileText, label: 'Контент' },
diff --git a/frontend/src/pages/admin/AdminPromoCodesPage.tsx b/frontend/src/pages/admin/AdminPromoCodesPage.tsx
new file mode 100644
index 0000000..1fdeef1
--- /dev/null
+++ b/frontend/src/pages/admin/AdminPromoCodesPage.tsx
@@ -0,0 +1,681 @@
+import { useState, useEffect, useCallback } from 'react'
+import { promoApi } from '@/api'
+import type { PromoCode, PromoCodeCreate, PromoCodeRedemption } from '@/types'
+import { useToast } from '@/store/toast'
+import { useConfirm } from '@/store/confirm'
+import { NeonButton, Input, GlassCard } from '@/components/ui'
+import {
+ Gift, Plus, Trash2, Edit, Users, Copy, Check, X,
+ Eye, Loader2, Coins
+} from 'lucide-react'
+import clsx from 'clsx'
+
+// Format date for display
+function formatDate(dateStr: string | null): string {
+ if (!dateStr) return '—'
+ return new Date(dateStr).toLocaleDateString('ru-RU', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ })
+}
+
+// Format date for input
+function formatDateForInput(dateStr: string | null): string {
+ if (!dateStr) return ''
+ const date = new Date(dateStr)
+ return date.toISOString().slice(0, 16)
+}
+
+export function AdminPromoCodesPage() {
+ const [promoCodes, setPromoCodes] = useState
([])
+ const [loading, setLoading] = useState(true)
+ const [includeInactive, setIncludeInactive] = useState(false)
+
+ // Create modal state
+ const [showCreateModal, setShowCreateModal] = useState(false)
+ const [createData, setCreateData] = useState({
+ code: '',
+ coins_amount: 100,
+ max_uses: null,
+ valid_from: null,
+ valid_until: null,
+ })
+ const [autoGenerate, setAutoGenerate] = useState(true)
+ const [creating, setCreating] = useState(false)
+
+ // Edit modal state
+ const [editingPromo, setEditingPromo] = useState(null)
+ const [editData, setEditData] = useState({
+ is_active: true,
+ max_uses: null as number | null,
+ valid_until: '',
+ })
+ const [saving, setSaving] = useState(false)
+
+ // Redemptions modal state
+ const [viewingRedemptions, setViewingRedemptions] = useState(null)
+ const [redemptions, setRedemptions] = useState([])
+ const [loadingRedemptions, setLoadingRedemptions] = useState(false)
+
+ // Copied state for code
+ const [copiedCode, setCopiedCode] = useState(null)
+
+ const toast = useToast()
+ const confirm = useConfirm()
+
+ const loadPromoCodes = useCallback(async () => {
+ setLoading(true)
+ try {
+ const response = await promoApi.admin.list(includeInactive)
+ setPromoCodes(response.data)
+ } catch {
+ toast.error('Ошибка загрузки промокодов')
+ } finally {
+ setLoading(false)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [includeInactive])
+
+ useEffect(() => {
+ loadPromoCodes()
+ }, [loadPromoCodes])
+
+ const handleCreate = async () => {
+ if (!autoGenerate && !createData.code?.trim()) {
+ toast.error('Введите код или включите автогенерацию')
+ return
+ }
+ if (createData.coins_amount < 1) {
+ toast.error('Количество монет должно быть больше 0')
+ return
+ }
+
+ setCreating(true)
+ try {
+ const response = await promoApi.admin.create({
+ ...createData,
+ code: autoGenerate ? null : createData.code,
+ })
+ setPromoCodes([response.data, ...promoCodes])
+ toast.success(`Промокод ${response.data.code} создан`)
+ setShowCreateModal(false)
+ resetCreateForm()
+ } catch (error: unknown) {
+ const err = error as { response?: { data?: { detail?: string } } }
+ toast.error(err.response?.data?.detail || 'Ошибка создания промокода')
+ } finally {
+ setCreating(false)
+ }
+ }
+
+ const resetCreateForm = () => {
+ setCreateData({
+ code: '',
+ coins_amount: 100,
+ max_uses: null,
+ valid_from: null,
+ valid_until: null,
+ })
+ setAutoGenerate(true)
+ }
+
+ const handleEdit = (promo: PromoCode) => {
+ setEditingPromo(promo)
+ setEditData({
+ is_active: promo.is_active,
+ max_uses: promo.max_uses,
+ valid_until: formatDateForInput(promo.valid_until),
+ })
+ }
+
+ const handleSaveEdit = async () => {
+ if (!editingPromo) return
+
+ setSaving(true)
+ try {
+ const response = await promoApi.admin.update(editingPromo.id, {
+ is_active: editData.is_active,
+ max_uses: editData.max_uses,
+ valid_until: editData.valid_until ? new Date(editData.valid_until).toISOString() : null,
+ })
+ setPromoCodes(promoCodes.map(p => p.id === response.data.id ? response.data : p))
+ toast.success('Промокод обновлён')
+ setEditingPromo(null)
+ } catch {
+ toast.error('Ошибка обновления промокода')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleDelete = async (promo: PromoCode) => {
+ const confirmed = await confirm({
+ title: 'Удалить промокод',
+ message: `Вы уверены, что хотите удалить промокод ${promo.code}?`,
+ confirmText: 'Удалить',
+ variant: 'danger',
+ })
+ if (!confirmed) return
+
+ try {
+ await promoApi.admin.delete(promo.id)
+ setPromoCodes(promoCodes.filter(p => p.id !== promo.id))
+ toast.success('Промокод удалён')
+ } catch {
+ toast.error('Ошибка удаления промокода')
+ }
+ }
+
+ const handleToggleActive = async (promo: PromoCode) => {
+ try {
+ const response = await promoApi.admin.update(promo.id, {
+ is_active: !promo.is_active,
+ })
+ setPromoCodes(promoCodes.map(p => p.id === response.data.id ? response.data : p))
+ toast.success(response.data.is_active ? 'Промокод активирован' : 'Промокод деактивирован')
+ } catch {
+ toast.error('Ошибка обновления промокода')
+ }
+ }
+
+ const handleViewRedemptions = async (promo: PromoCode) => {
+ setViewingRedemptions(promo)
+ setLoadingRedemptions(true)
+ try {
+ const response = await promoApi.admin.getRedemptions(promo.id)
+ setRedemptions(response.data)
+ } catch {
+ toast.error('Ошибка загрузки использований')
+ } finally {
+ setLoadingRedemptions(false)
+ }
+ }
+
+ const handleCopyCode = (code: string) => {
+ navigator.clipboard.writeText(code)
+ setCopiedCode(code)
+ setTimeout(() => setCopiedCode(null), 2000)
+ }
+
+ const isPromoValid = (promo: PromoCode): boolean => {
+ if (!promo.is_active) return false
+ const now = new Date()
+ if (promo.valid_from && new Date(promo.valid_from) > now) return false
+ if (promo.valid_until && new Date(promo.valid_until) < now) return false
+ if (promo.max_uses !== null && promo.uses_count >= promo.max_uses) return false
+ return true
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
Промокоды
+
Управление промокодами на монеты
+
+
+
+
+
+
+
setShowCreateModal(true)}
+ icon={}
+ >
+ Создать
+
+
+
+
+ {/* Stats */}
+
+
+
+
+
+
+
+
{promoCodes.length}
+
Всего кодов
+
+
+
+
+
+
+
+
+
+
+ {promoCodes.filter(p => isPromoValid(p)).length}
+
+
Активных
+
+
+
+
+
+
+
+
+
+
+ {promoCodes.reduce((sum, p) => sum + p.uses_count, 0)}
+
+
Использований
+
+
+
+
+
+
+
+
+
+
+ {promoCodes.reduce((sum, p) => sum + p.coins_amount * p.uses_count, 0)}
+
+
Выдано монет
+
+
+
+
+
+ {/* Table */}
+
+ {loading ? (
+
+
+
+ ) : promoCodes.length === 0 ? (
+
+
+
Промокоды не найдены
+
+ ) : (
+
+
+
+
+ | Код |
+ Монет |
+ Лимит |
+ Использований |
+ Срок |
+ Статус |
+ Создан |
+ Действия |
+
+
+
+ {promoCodes.map((promo) => (
+
+ |
+
+
+ {promo.code}
+
+
+
+ |
+
+ {promo.coins_amount}
+ |
+
+
+ {promo.max_uses !== null ? promo.max_uses : '∞'}
+
+ |
+
+
+ |
+
+ {promo.valid_until ? formatDate(promo.valid_until) : '—'}
+ |
+
+ {isPromoValid(promo) ? (
+
+ Активен
+
+ ) : (
+
+ Неактивен
+
+ )}
+ |
+
+ {formatDate(promo.created_at)}
+ |
+
+
+
+
+
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+
+ {/* Create Modal */}
+ {showCreateModal && (
+
+
+
+
Создать промокод
+
+
+
+
+ {/* Auto generate toggle */}
+
+
+ {/* Manual code input */}
+ {!autoGenerate && (
+
+
+ setCreateData({ ...createData, code: e.target.value.toUpperCase() })}
+ />
+
+ )}
+
+ {/* Coins amount */}
+
+
+ setCreateData({ ...createData, coins_amount: parseInt(e.target.value) || 0 })}
+ />
+
+
+ {/* Max uses */}
+
+
+ setCreateData({ ...createData, max_uses: e.target.value ? parseInt(e.target.value) : null })}
+ />
+
+
+ {/* Valid until */}
+
+
+ setCreateData({ ...createData, valid_until: e.target.value || null })}
+ />
+
+
+
+ }
+ >
+ Создать
+
+ {
+ setShowCreateModal(false)
+ resetCreateForm()
+ }}
+ >
+ Отмена
+
+
+
+
+
+ )}
+
+ {/* Edit Modal */}
+ {editingPromo && (
+
+
+
+
Редактировать промокод
+
+
+
+
+ {/* Code display */}
+
+
Код
+
{editingPromo.code}
+
+
+ {/* Active toggle */}
+
+
+ {/* Max uses */}
+
+
+ setEditData({ ...editData, max_uses: e.target.value ? parseInt(e.target.value) : null })}
+ />
+
+
+ {/* Valid until */}
+
+
+ setEditData({ ...editData, valid_until: e.target.value })}
+ />
+
+
+
+ }
+ >
+ Сохранить
+
+ setEditingPromo(null)}
+ >
+ Отмена
+
+
+
+
+
+ )}
+
+ {/* Redemptions Modal */}
+ {viewingRedemptions && (
+
+
+
+
+
Использования промокода
+
+ {viewingRedemptions.code}
+ {' • '}
+ {viewingRedemptions.uses_count} использований
+
+
+
+
+
+
+ {loadingRedemptions ? (
+
+
+
+ ) : redemptions.length === 0 ? (
+
+
+
Пока никто не использовал этот код
+
+ ) : (
+
+ {redemptions.map((r) => (
+
+
+
+
+
+
+
{r.user.nickname}
+
{formatDate(r.redeemed_at)}
+
+
+
+ +{r.coins_awarded}
+
+
+ ))}
+
+ )}
+
+
+
+ {
+ setViewingRedemptions(null)
+ setRedemptions([])
+ }}
+ className="w-full"
+ >
+ Закрыть
+
+
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/admin/index.ts b/frontend/src/pages/admin/index.ts
index e7822be..bf3ff09 100644
--- a/frontend/src/pages/admin/index.ts
+++ b/frontend/src/pages/admin/index.ts
@@ -5,3 +5,4 @@ export { AdminMarathonsPage } from './AdminMarathonsPage'
export { AdminLogsPage } from './AdminLogsPage'
export { AdminBroadcastPage } from './AdminBroadcastPage'
export { AdminContentPage } from './AdminContentPage'
+export { AdminPromoCodesPage } from './AdminPromoCodesPage'
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 213b994..83e08f8 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -863,3 +863,49 @@ export const ITEM_TYPE_NAMES: Record = {
background: 'Фон профиля',
consumable: 'Расходуемое',
}
+
+// === Promo Code types ===
+
+export interface PromoCode {
+ id: number
+ code: string
+ coins_amount: number
+ max_uses: number | null
+ uses_count: number
+ is_active: boolean
+ valid_from: string | null
+ valid_until: string | null
+ created_at: string
+ created_by_nickname: string | null
+}
+
+export interface PromoCodeCreate {
+ code?: string | null // null = auto-generate
+ coins_amount: number
+ max_uses?: number | null
+ valid_from?: string | null
+ valid_until?: string | null
+}
+
+export interface PromoCodeUpdate {
+ is_active?: boolean
+ max_uses?: number | null
+ valid_until?: string | null
+}
+
+export interface PromoCodeRedemption {
+ id: number
+ user: {
+ id: number
+ nickname: string
+ }
+ coins_awarded: number
+ redeemed_at: string
+}
+
+export interface PromoCodeRedeemResponse {
+ success: boolean
+ coins_awarded: number
+ new_balance: number
+ message: string
+}