Promocode system

This commit is contained in:
2026-01-08 10:02:15 +07:00
parent 1751c4dd4c
commit e63d6c8489
19 changed files with 1443 additions and 7 deletions

View File

@@ -0,0 +1,58 @@
"""Add promo codes system
Revision ID: 028_add_promo_codes
Revises: 027_consumables_redesign
Create Date: 2026-01-08
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '028_add_promo_codes'
down_revision: Union[str, None] = '027_consumables_redesign'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create promo_codes table
op.create_table(
'promo_codes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(50), nullable=False),
sa.Column('coins_amount', sa.Integer(), nullable=False),
sa.Column('max_uses', sa.Integer(), nullable=True),
sa.Column('uses_count', sa.Integer(), nullable=False, server_default='0'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('created_by_id', sa.Integer(), nullable=False),
sa.Column('valid_from', sa.DateTime(), nullable=True),
sa.Column('valid_until', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ondelete='CASCADE'),
)
op.create_index('ix_promo_codes_code', 'promo_codes', ['code'], unique=True)
# Create promo_code_redemptions table
op.create_table(
'promo_code_redemptions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('promo_code_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('coins_awarded', sa.Integer(), nullable=False),
sa.Column('redeemed_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['promo_code_id'], ['promo_codes.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.UniqueConstraint('promo_code_id', 'user_id', name='uq_promo_code_user'),
)
op.create_index('ix_promo_code_redemptions_user_id', 'promo_code_redemptions', ['user_id'])
def downgrade() -> None:
op.drop_table('promo_code_redemptions')
op.drop_table('promo_codes')

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content, shop from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content, shop, promo
router = APIRouter(prefix="/api/v1") router = APIRouter(prefix="/api/v1")
@@ -17,3 +17,4 @@ router.include_router(assignments.router)
router.include_router(telegram.router) router.include_router(telegram.router)
router.include_router(content.router) router.include_router(content.router)
router.include_router(shop.router) router.include_router(shop.router)
router.include_router(promo.router)

299
backend/app/api/v1/promo.py Normal file
View File

@@ -0,0 +1,299 @@
"""
Promo Code API endpoints - user redemption and admin management
"""
import secrets
import string
from datetime import datetime
from fastapi import APIRouter, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.api.deps import CurrentUser, DbSession, require_admin_with_2fa
from app.models import User, CoinTransaction, CoinTransactionType
from app.models.promo_code import PromoCode, PromoCodeRedemption
from app.schemas.promo_code import (
PromoCodeCreate,
PromoCodeUpdate,
PromoCodeResponse,
PromoCodeRedeemRequest,
PromoCodeRedeemResponse,
PromoCodeRedemptionResponse,
PromoCodeRedemptionUser,
)
from app.schemas.common import MessageResponse
router = APIRouter(prefix="/promo", tags=["promo"])
def generate_promo_code(length: int = 8) -> str:
"""Generate a random promo code"""
chars = string.ascii_uppercase + string.digits
return ''.join(secrets.choice(chars) for _ in range(length))
# === User endpoints ===
@router.post("/redeem", response_model=PromoCodeRedeemResponse)
async def redeem_promo_code(
data: PromoCodeRedeemRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Redeem a promo code to receive coins"""
# Find promo code
result = await db.execute(
select(PromoCode).where(PromoCode.code == data.code.upper().strip())
)
promo = result.scalar_one_or_none()
if not promo:
raise HTTPException(status_code=404, detail="Промокод не найден")
# Check if valid
if not promo.is_active:
raise HTTPException(status_code=400, detail="Промокод деактивирован")
now = datetime.utcnow()
if promo.valid_from and now < promo.valid_from:
raise HTTPException(status_code=400, detail="Промокод ещё не активен")
if promo.valid_until and now > promo.valid_until:
raise HTTPException(status_code=400, detail="Промокод истёк")
if promo.max_uses is not None and promo.uses_count >= promo.max_uses:
raise HTTPException(status_code=400, detail="Лимит использований исчерпан")
# Check if user already redeemed
result = await db.execute(
select(PromoCodeRedemption).where(
PromoCodeRedemption.promo_code_id == promo.id,
PromoCodeRedemption.user_id == current_user.id,
)
)
existing = result.scalar_one_or_none()
if existing:
raise HTTPException(status_code=400, detail="Вы уже использовали этот промокод")
# Create redemption record
redemption = PromoCodeRedemption(
promo_code_id=promo.id,
user_id=current_user.id,
coins_awarded=promo.coins_amount,
)
db.add(redemption)
# Update uses count
promo.uses_count += 1
# Award coins
transaction = CoinTransaction(
user_id=current_user.id,
amount=promo.coins_amount,
transaction_type=CoinTransactionType.PROMO_CODE.value,
reference_type="promo_code",
reference_id=promo.id,
description=f"Промокод: {promo.code}",
)
db.add(transaction)
current_user.coins_balance += promo.coins_amount
await db.commit()
await db.refresh(current_user)
return PromoCodeRedeemResponse(
success=True,
coins_awarded=promo.coins_amount,
new_balance=current_user.coins_balance,
message=f"Вы получили {promo.coins_amount} монет!",
)
# === Admin endpoints ===
@router.get("/admin/list", response_model=list[PromoCodeResponse])
async def admin_list_promo_codes(
current_user: CurrentUser,
db: DbSession,
include_inactive: bool = False,
):
"""Get all promo codes (admin only)"""
require_admin_with_2fa(current_user)
query = select(PromoCode).options(selectinload(PromoCode.created_by))
if not include_inactive:
query = query.where(PromoCode.is_active == True)
query = query.order_by(PromoCode.created_at.desc())
result = await db.execute(query)
promos = result.scalars().all()
return [
PromoCodeResponse(
id=p.id,
code=p.code,
coins_amount=p.coins_amount,
max_uses=p.max_uses,
uses_count=p.uses_count,
is_active=p.is_active,
valid_from=p.valid_from,
valid_until=p.valid_until,
created_at=p.created_at,
created_by_nickname=p.created_by.nickname if p.created_by else None,
)
for p in promos
]
@router.post("/admin/create", response_model=PromoCodeResponse)
async def admin_create_promo_code(
data: PromoCodeCreate,
current_user: CurrentUser,
db: DbSession,
):
"""Create a new promo code (admin only)"""
require_admin_with_2fa(current_user)
# Generate or use provided code
code = data.code.upper().strip() if data.code else generate_promo_code()
# Check uniqueness
result = await db.execute(
select(PromoCode).where(PromoCode.code == code)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail=f"Промокод '{code}' уже существует")
promo = PromoCode(
code=code,
coins_amount=data.coins_amount,
max_uses=data.max_uses,
valid_from=data.valid_from,
valid_until=data.valid_until,
created_by_id=current_user.id,
)
db.add(promo)
await db.commit()
await db.refresh(promo)
return PromoCodeResponse(
id=promo.id,
code=promo.code,
coins_amount=promo.coins_amount,
max_uses=promo.max_uses,
uses_count=promo.uses_count,
is_active=promo.is_active,
valid_from=promo.valid_from,
valid_until=promo.valid_until,
created_at=promo.created_at,
created_by_nickname=current_user.nickname,
)
@router.put("/admin/{promo_id}", response_model=PromoCodeResponse)
async def admin_update_promo_code(
promo_id: int,
data: PromoCodeUpdate,
current_user: CurrentUser,
db: DbSession,
):
"""Update a promo code (admin only)"""
require_admin_with_2fa(current_user)
result = await db.execute(
select(PromoCode)
.options(selectinload(PromoCode.created_by))
.where(PromoCode.id == promo_id)
)
promo = result.scalar_one_or_none()
if not promo:
raise HTTPException(status_code=404, detail="Промокод не найден")
if data.is_active is not None:
promo.is_active = data.is_active
if data.max_uses is not None:
promo.max_uses = data.max_uses
if data.valid_until is not None:
promo.valid_until = data.valid_until
await db.commit()
await db.refresh(promo)
return PromoCodeResponse(
id=promo.id,
code=promo.code,
coins_amount=promo.coins_amount,
max_uses=promo.max_uses,
uses_count=promo.uses_count,
is_active=promo.is_active,
valid_from=promo.valid_from,
valid_until=promo.valid_until,
created_at=promo.created_at,
created_by_nickname=promo.created_by.nickname if promo.created_by else None,
)
@router.delete("/admin/{promo_id}", response_model=MessageResponse)
async def admin_delete_promo_code(
promo_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Delete a promo code (admin only)"""
require_admin_with_2fa(current_user)
result = await db.execute(
select(PromoCode).where(PromoCode.id == promo_id)
)
promo = result.scalar_one_or_none()
if not promo:
raise HTTPException(status_code=404, detail="Промокод не найден")
await db.delete(promo)
await db.commit()
return MessageResponse(message=f"Промокод '{promo.code}' удалён")
@router.get("/admin/{promo_id}/redemptions", response_model=list[PromoCodeRedemptionResponse])
async def admin_get_promo_redemptions(
promo_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get list of users who redeemed a promo code (admin only)"""
require_admin_with_2fa(current_user)
# Check promo exists
result = await db.execute(
select(PromoCode).where(PromoCode.id == promo_id)
)
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Промокод не найден")
# Get redemptions
result = await db.execute(
select(PromoCodeRedemption)
.options(selectinload(PromoCodeRedemption.user))
.where(PromoCodeRedemption.promo_code_id == promo_id)
.order_by(PromoCodeRedemption.redeemed_at.desc())
)
redemptions = result.scalars().all()
return [
PromoCodeRedemptionResponse(
id=r.id,
user=PromoCodeRedemptionUser(
id=r.user.id,
nickname=r.user.nickname,
),
coins_awarded=r.coins_awarded,
redeemed_at=r.redeemed_at,
)
for r in redemptions
]

View File

@@ -10,7 +10,7 @@ from app.api.deps import CurrentUser, DbSession, require_participant, require_ad
from app.models import ( from app.models import (
User, Marathon, Participant, Assignment, AssignmentStatus, User, Marathon, Participant, Assignment, AssignmentStatus,
ShopItem, UserInventory, CoinTransaction, ShopItemType, ShopItem, UserInventory, CoinTransaction, ShopItemType,
CertificationStatus, CertificationStatus, Challenge, Game,
) )
from app.schemas import ( from app.schemas import (
ShopItemResponse, ShopItemCreate, ShopItemUpdate, ShopItemResponse, ShopItemCreate, ShopItemUpdate,
@@ -19,8 +19,9 @@ from app.schemas import (
EquipItemRequest, EquipItemResponse, EquipItemRequest, EquipItemResponse,
CoinTransactionResponse, CoinsBalanceResponse, AdminCoinsRequest, CoinTransactionResponse, CoinsBalanceResponse, AdminCoinsRequest,
CertificationRequestSchema, CertificationReviewRequest, CertificationStatusResponse, CertificationRequestSchema, CertificationReviewRequest, CertificationStatusResponse,
ConsumablesStatusResponse, MessageResponse, ConsumablesStatusResponse, MessageResponse, SwapCandidate,
) )
from app.schemas.user import UserPublic
from app.services.shop import shop_service from app.services.shop import shop_service
from app.services.coins import coins_service from app.services.coins import coins_service
from app.services.consumables import consumables_service from app.services.consumables import consumables_service
@@ -296,6 +297,91 @@ async def get_consumables_status(
) )
@router.get("/copycat-candidates/{marathon_id}", response_model=list[SwapCandidate])
async def get_copycat_candidates(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get participants with active assignments available for copycat (no event required)"""
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
participant = await require_participant(db, current_user.id, marathon_id)
# Get all participants except current user with active assignments
# Support both challenge assignments and playthrough assignments
result = await db.execute(
select(Participant, Assignment, Challenge, Game)
.join(Assignment, Assignment.participant_id == Participant.id)
.outerjoin(Challenge, Assignment.challenge_id == Challenge.id)
.outerjoin(Game, Challenge.game_id == Game.id)
.options(selectinload(Participant.user))
.where(
Participant.marathon_id == marathon_id,
Participant.id != participant.id,
Assignment.status == AssignmentStatus.ACTIVE.value,
)
)
rows = result.all()
candidates = []
for p, assignment, challenge, game in rows:
# For playthrough assignments, challenge is None
if assignment.is_playthrough:
# Need to get game info for playthrough
game_result = await db.execute(
select(Game).where(Game.id == assignment.game_id)
)
playthrough_game = game_result.scalar_one_or_none()
if playthrough_game:
candidates.append(SwapCandidate(
participant_id=p.id,
user=UserPublic(
id=p.user.id,
nickname=p.user.nickname,
avatar_url=p.user.avatar_url,
role=p.user.role,
telegram_avatar_url=p.user.telegram_avatar_url,
created_at=p.user.created_at,
equipped_frame=None,
equipped_title=None,
equipped_name_color=None,
equipped_background=None,
),
challenge_title=f"Прохождение: {playthrough_game.title}",
challenge_description=playthrough_game.playthrough_description or "Прохождение игры",
challenge_points=playthrough_game.playthrough_points or 0,
challenge_difficulty="medium",
game_title=playthrough_game.title,
))
elif challenge and game:
candidates.append(SwapCandidate(
participant_id=p.id,
user=UserPublic(
id=p.user.id,
nickname=p.user.nickname,
avatar_url=p.user.avatar_url,
role=p.user.role,
telegram_avatar_url=p.user.telegram_avatar_url,
created_at=p.user.created_at,
equipped_frame=None,
equipped_title=None,
equipped_name_color=None,
equipped_background=None,
),
challenge_title=challenge.title,
challenge_description=challenge.description,
challenge_points=challenge.points,
challenge_difficulty=challenge.difficulty,
game_title=game.title,
))
return candidates
# === Coins === # === Coins ===
@router.get("/balance", response_model=CoinsBalanceResponse) @router.get("/balance", response_model=CoinsBalanceResponse)

View File

@@ -17,6 +17,7 @@ from app.models.shop import ShopItem, ShopItemType, ItemRarity, ConsumableType
from app.models.inventory import UserInventory from app.models.inventory import UserInventory
from app.models.coin_transaction import CoinTransaction, CoinTransactionType from app.models.coin_transaction import CoinTransaction, CoinTransactionType
from app.models.consumable_usage import ConsumableUsage from app.models.consumable_usage import ConsumableUsage
from app.models.promo_code import PromoCode, PromoCodeRedemption
__all__ = [ __all__ = [
"User", "User",
@@ -62,4 +63,6 @@ __all__ = [
"CoinTransaction", "CoinTransaction",
"CoinTransactionType", "CoinTransactionType",
"ConsumableUsage", "ConsumableUsage",
"PromoCode",
"PromoCodeRedemption",
] ]

View File

@@ -20,6 +20,7 @@ class CoinTransactionType(str, Enum):
REFUND = "refund" REFUND = "refund"
ADMIN_GRANT = "admin_grant" ADMIN_GRANT = "admin_grant"
ADMIN_DEDUCT = "admin_deduct" ADMIN_DEDUCT = "admin_deduct"
PROMO_CODE = "promo_code"
class CoinTransaction(Base): class CoinTransaction(Base):

View File

@@ -0,0 +1,67 @@
"""
Promo Code models for coins distribution
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Boolean, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class PromoCode(Base):
"""Promo code for giving coins to users"""
__tablename__ = "promo_codes"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
code: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
coins_amount: Mapped[int] = mapped_column(Integer, nullable=False)
max_uses: Mapped[int | None] = mapped_column(Integer, nullable=True) # None = unlimited
uses_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_by_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
valid_from: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
valid_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), nullable=False)
# Relationships
created_by: Mapped["User"] = relationship("User", foreign_keys=[created_by_id])
redemptions: Mapped[list["PromoCodeRedemption"]] = relationship(
"PromoCodeRedemption", back_populates="promo_code", cascade="all, delete-orphan"
)
def is_valid(self) -> bool:
"""Check if promo code is currently valid"""
if not self.is_active:
return False
now = datetime.utcnow()
if self.valid_from and now < self.valid_from:
return False
if self.valid_until and now > self.valid_until:
return False
if self.max_uses is not None and self.uses_count >= self.max_uses:
return False
return True
class PromoCodeRedemption(Base):
"""Record of promo code redemption by a user"""
__tablename__ = "promo_code_redemptions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
promo_code_id: Mapped[int] = mapped_column(ForeignKey("promo_codes.id", ondelete="CASCADE"), nullable=False)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
coins_awarded: Mapped[int] = mapped_column(Integer, nullable=False)
redeemed_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), nullable=False)
__table_args__ = (
UniqueConstraint('promo_code_id', 'user_id', name='uq_promo_code_user'),
)
# Relationships
promo_code: Mapped["PromoCode"] = relationship("PromoCode", back_populates="redemptions")
user: Mapped["User"] = relationship("User")

View File

@@ -124,6 +124,15 @@ from app.schemas.shop import (
CertificationStatusResponse, CertificationStatusResponse,
ConsumablesStatusResponse, ConsumablesStatusResponse,
) )
from app.schemas.promo_code import (
PromoCodeCreate,
PromoCodeUpdate,
PromoCodeResponse,
PromoCodeRedeemRequest,
PromoCodeRedeemResponse,
PromoCodeRedemptionResponse,
PromoCodeRedemptionUser,
)
from app.schemas.user import ShopItemPublic from app.schemas.user import ShopItemPublic
__all__ = [ __all__ = [
@@ -243,4 +252,12 @@ __all__ = [
"CertificationReviewRequest", "CertificationReviewRequest",
"CertificationStatusResponse", "CertificationStatusResponse",
"ConsumablesStatusResponse", "ConsumablesStatusResponse",
# Promo
"PromoCodeCreate",
"PromoCodeUpdate",
"PromoCodeResponse",
"PromoCodeRedeemRequest",
"PromoCodeRedeemResponse",
"PromoCodeRedemptionResponse",
"PromoCodeRedemptionUser",
] ]

View File

@@ -0,0 +1,74 @@
"""
Promo Code schemas
"""
from datetime import datetime
from pydantic import BaseModel, Field
# === Create/Update ===
class PromoCodeCreate(BaseModel):
"""Schema for creating a promo code"""
code: str | None = Field(None, min_length=3, max_length=50) # None = auto-generate
coins_amount: int = Field(..., ge=1, le=100000)
max_uses: int | None = Field(None, ge=1) # None = unlimited
valid_from: datetime | None = None
valid_until: datetime | None = None
class PromoCodeUpdate(BaseModel):
"""Schema for updating a promo code"""
is_active: bool | None = None
max_uses: int | None = None
valid_until: datetime | None = None
# === Response ===
class PromoCodeResponse(BaseModel):
"""Schema for promo code in responses"""
id: int
code: str
coins_amount: int
max_uses: int | None
uses_count: int
is_active: bool
valid_from: datetime | None
valid_until: datetime | None
created_at: datetime
created_by_nickname: str | None = None
class Config:
from_attributes = True
class PromoCodeRedemptionUser(BaseModel):
"""User info for redemption"""
id: int
nickname: str
class PromoCodeRedemptionResponse(BaseModel):
"""Schema for redemption record"""
id: int
user: PromoCodeRedemptionUser
coins_awarded: int
redeemed_at: datetime
class Config:
from_attributes = True
# === Redeem ===
class PromoCodeRedeemRequest(BaseModel):
"""Schema for redeeming a promo code"""
code: str = Field(..., min_length=1, max_length=50)
class PromoCodeRedeemResponse(BaseModel):
"""Schema for redeem response"""
success: bool
coins_awarded: int
new_balance: int
message: str

View File

@@ -37,6 +37,7 @@ import {
AdminLogsPage, AdminLogsPage,
AdminBroadcastPage, AdminBroadcastPage,
AdminContentPage, AdminContentPage,
AdminPromoCodesPage,
} from '@/pages/admin' } from '@/pages/admin'
// Protected route wrapper // Protected route wrapper
@@ -229,6 +230,7 @@ function App() {
<Route index element={<AdminDashboardPage />} /> <Route index element={<AdminDashboardPage />} />
<Route path="users" element={<AdminUsersPage />} /> <Route path="users" element={<AdminUsersPage />} />
<Route path="marathons" element={<AdminMarathonsPage />} /> <Route path="marathons" element={<AdminMarathonsPage />} />
<Route path="promo" element={<AdminPromoCodesPage />} />
<Route path="logs" element={<AdminLogsPage />} /> <Route path="logs" element={<AdminLogsPage />} />
<Route path="broadcast" element={<AdminBroadcastPage />} /> <Route path="broadcast" element={<AdminBroadcastPage />} />
<Route path="content" element={<AdminContentPage />} /> <Route path="content" element={<AdminContentPage />} />

View File

@@ -10,3 +10,4 @@ export { assignmentsApi } from './assignments'
export { usersApi } from './users' export { usersApi } from './users'
export { telegramApi } from './telegram' export { telegramApi } from './telegram'
export { shopApi } from './shop' export { shopApi } from './shop'
export { promoApi } from './promo'

34
frontend/src/api/promo.ts Normal file
View File

@@ -0,0 +1,34 @@
import client from './client'
import type {
PromoCode,
PromoCodeCreate,
PromoCodeUpdate,
PromoCodeRedemption,
PromoCodeRedeemResponse,
} from '@/types'
export const promoApi = {
// User endpoint - redeem promo code
redeem: (code: string) =>
client.post<PromoCodeRedeemResponse>('/promo/redeem', { code }),
// Admin endpoints
admin: {
list: (includeInactive = false) =>
client.get<PromoCode[]>('/promo/admin/list', {
params: { include_inactive: includeInactive },
}),
create: (data: PromoCodeCreate) =>
client.post<PromoCode>('/promo/admin/create', data),
update: (id: number, data: PromoCodeUpdate) =>
client.put<PromoCode>(`/promo/admin/${id}`, data),
delete: (id: number) =>
client.delete<{ message: string }>(`/promo/admin/${id}`),
getRedemptions: (id: number) =>
client.get<PromoCodeRedemption[]>(`/promo/admin/${id}/redemptions`),
},
}

View File

@@ -10,6 +10,7 @@ import type {
CoinTransaction, CoinTransaction,
ConsumablesStatus, ConsumablesStatus,
UserCosmetics, UserCosmetics,
SwapCandidate,
} from '@/types' } from '@/types'
export const shopApi = { export const shopApi = {
@@ -84,6 +85,12 @@ export const shopApi = {
return response.data return response.data
}, },
// Получить кандидатов для Copycat (участники с активными заданиями)
getCopycatCandidates: async (marathonId: number): Promise<SwapCandidate[]> => {
const response = await client.get<SwapCandidate[]>(`/shop/copycat-candidates/${marathonId}`)
return response.data
},
// === Монеты === // === Монеты ===
// Получить баланс и последние транзакции // Получить баланс и последние транзакции

View File

@@ -567,7 +567,7 @@ export function PlayPage() {
if (!id) return if (!id) return
setIsLoadingCopycatCandidates(true) setIsLoadingCopycatCandidates(true)
try { try {
const candidates = await eventsApi.getSwapCandidates(parseInt(id)) const candidates = await shopApi.getCopycatCandidates(parseInt(id))
setCopycatCandidates(candidates) setCopycatCandidates(candidates)
} catch (error) { } catch (error) {
console.error('Failed to load copycat candidates:', error) console.error('Failed to load copycat candidates:', error)

View File

@@ -4,7 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { usersApi, telegramApi, authApi } from '@/api' import { usersApi, telegramApi, authApi, promoApi } from '@/api'
import type { UserStats, ShopItemPublic } from '@/types' import type { UserStats, ShopItemPublic } from '@/types'
import { useToast } from '@/store/toast' import { useToast } from '@/store/toast'
import { import {
@@ -14,7 +14,7 @@ import {
User, Camera, Trophy, Target, CheckCircle, Flame, User, Camera, Trophy, Target, CheckCircle, Flame,
Loader2, MessageCircle, Link2, Link2Off, ExternalLink, Loader2, MessageCircle, Link2, Link2Off, ExternalLink,
Eye, EyeOff, Save, KeyRound, Shield, Bell, Sparkles, Eye, EyeOff, Save, KeyRound, Shield, Bell, Sparkles,
AlertTriangle, FileCheck, Backpack, Edit3 AlertTriangle, FileCheck, Backpack, Edit3, Gift
} from 'lucide-react' } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
@@ -289,6 +289,10 @@ export function ProfilePage() {
const [notifyModeration, setNotifyModeration] = useState(user?.notify_moderation ?? true) const [notifyModeration, setNotifyModeration] = useState(user?.notify_moderation ?? true)
const [notificationUpdating, setNotificationUpdating] = useState<string | null>(null) const [notificationUpdating, setNotificationUpdating] = useState<string | null>(null)
// Promo code state
const [promoCode, setPromoCode] = useState('')
const [isRedeemingPromo, setIsRedeemingPromo] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
// Forms // Forms
@@ -526,6 +530,27 @@ export function ProfilePage() {
} }
} }
// Redeem promo code
const handleRedeemPromo = async (e: React.FormEvent) => {
e.preventDefault()
if (!promoCode.trim()) return
setIsRedeemingPromo(true)
try {
const response = await promoApi.redeem(promoCode.trim())
toast.success(response.data.message)
setPromoCode('')
// Update coin balance in store
updateUser({ coins_balance: response.data.new_balance })
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
const message = err.response?.data?.detail || 'Не удалось активировать промокод'
toast.error(message)
} finally {
setIsRedeemingPromo(false)
}
}
const isLinked = !!user?.telegram_id const isLinked = !!user?.telegram_id
const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
@@ -773,6 +798,37 @@ export function ProfilePage() {
)} )}
</div> </div>
{/* Promo Code */}
<GlassCard>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-yellow-500/20 flex items-center justify-center">
<Gift className="w-5 h-5 text-yellow-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Промокод</h2>
<p className="text-sm text-gray-400">Введите код для получения монет</p>
</div>
</div>
<form onSubmit={handleRedeemPromo} className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<Input
placeholder="Введите промокод"
value={promoCode}
onChange={(e) => setPromoCode(e.target.value.toUpperCase())}
maxLength={50}
/>
</div>
<NeonButton
type="submit"
isLoading={isRedeemingPromo}
disabled={!promoCode.trim()}
icon={<Gift className="w-4 h-4" />}
>
Активировать
</NeonButton>
</form>
</GlassCard>
{/* Telegram */} {/* Telegram */}
<GlassCard> <GlassCard>
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">

View File

@@ -12,13 +12,15 @@ import {
Shield, Shield,
MessageCircle, MessageCircle,
Sparkles, Sparkles,
Lock Lock,
Gift
} from 'lucide-react' } from 'lucide-react'
const navItems = [ const navItems = [
{ to: '/admin', icon: LayoutDashboard, label: 'Дашборд', end: true }, { to: '/admin', icon: LayoutDashboard, label: 'Дашборд', end: true },
{ to: '/admin/users', icon: Users, label: 'Пользователи' }, { to: '/admin/users', icon: Users, label: 'Пользователи' },
{ to: '/admin/marathons', icon: Trophy, label: 'Марафоны' }, { to: '/admin/marathons', icon: Trophy, label: 'Марафоны' },
{ to: '/admin/promo', icon: Gift, label: 'Промокоды' },
{ to: '/admin/logs', icon: ScrollText, label: 'Логи' }, { to: '/admin/logs', icon: ScrollText, label: 'Логи' },
{ to: '/admin/broadcast', icon: Send, label: 'Рассылка' }, { to: '/admin/broadcast', icon: Send, label: 'Рассылка' },
{ to: '/admin/content', icon: FileText, label: 'Контент' }, { to: '/admin/content', icon: FileText, label: 'Контент' },

View File

@@ -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<PromoCode[]>([])
const [loading, setLoading] = useState(true)
const [includeInactive, setIncludeInactive] = useState(false)
// Create modal state
const [showCreateModal, setShowCreateModal] = useState(false)
const [createData, setCreateData] = useState<PromoCodeCreate>({
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<PromoCode | null>(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<PromoCode | null>(null)
const [redemptions, setRedemptions] = useState<PromoCodeRedemption[]>([])
const [loadingRedemptions, setLoadingRedemptions] = useState(false)
// Copied state for code
const [copiedCode, setCopiedCode] = useState<string | null>(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 (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-yellow-500/20 border border-yellow-500/30">
<Gift className="w-6 h-6 text-yellow-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Промокоды</h1>
<p className="text-sm text-gray-400">Управление промокодами на монеты</p>
</div>
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 text-sm text-gray-400 cursor-pointer">
<input
type="checkbox"
checked={includeInactive}
onChange={(e) => setIncludeInactive(e.target.checked)}
className="rounded border-dark-600 bg-dark-700 text-neon-500 focus:ring-neon-500"
/>
Показать неактивные
</label>
<NeonButton
onClick={() => setShowCreateModal(true)}
icon={<Plus className="w-4 h-4" />}
>
Создать
</NeonButton>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<GlassCard className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center">
<Gift className="w-5 h-5 text-yellow-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">{promoCodes.length}</p>
<p className="text-sm text-gray-400">Всего кодов</p>
</div>
</div>
</GlassCard>
<GlassCard className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-green-500/20 flex items-center justify-center">
<Check className="w-5 h-5 text-green-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">
{promoCodes.filter(p => isPromoValid(p)).length}
</p>
<p className="text-sm text-gray-400">Активных</p>
</div>
</div>
</GlassCard>
<GlassCard className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
<Users className="w-5 h-5 text-blue-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">
{promoCodes.reduce((sum, p) => sum + p.uses_count, 0)}
</p>
<p className="text-sm text-gray-400">Использований</p>
</div>
</div>
</GlassCard>
<GlassCard className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
<Coins className="w-5 h-5 text-purple-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">
{promoCodes.reduce((sum, p) => sum + p.coins_amount * p.uses_count, 0)}
</p>
<p className="text-sm text-gray-400">Выдано монет</p>
</div>
</div>
</GlassCard>
</div>
{/* Table */}
<GlassCard className="overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 text-neon-500 animate-spin" />
</div>
) : promoCodes.length === 0 ? (
<div className="text-center py-12">
<Gift className="w-12 h-12 text-gray-600 mx-auto mb-3" />
<p className="text-gray-400">Промокоды не найдены</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-dark-600">
<th className="text-left p-4 text-sm font-medium text-gray-400">Код</th>
<th className="text-left p-4 text-sm font-medium text-gray-400">Монет</th>
<th className="text-left p-4 text-sm font-medium text-gray-400">Лимит</th>
<th className="text-left p-4 text-sm font-medium text-gray-400">Использований</th>
<th className="text-left p-4 text-sm font-medium text-gray-400">Срок</th>
<th className="text-left p-4 text-sm font-medium text-gray-400">Статус</th>
<th className="text-left p-4 text-sm font-medium text-gray-400">Создан</th>
<th className="text-right p-4 text-sm font-medium text-gray-400">Действия</th>
</tr>
</thead>
<tbody>
{promoCodes.map((promo) => (
<tr key={promo.id} className="border-b border-dark-600/50 hover:bg-dark-700/30">
<td className="p-4">
<div className="flex items-center gap-2">
<code className="px-2 py-1 bg-dark-700 rounded text-yellow-400 font-mono text-sm">
{promo.code}
</code>
<button
onClick={() => handleCopyCode(promo.code)}
className="p-1 text-gray-400 hover:text-white transition-colors"
title="Копировать"
>
{copiedCode === promo.code ? (
<Check className="w-4 h-4 text-green-400" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
</td>
<td className="p-4">
<span className="text-white font-medium">{promo.coins_amount}</span>
</td>
<td className="p-4">
<span className="text-gray-400">
{promo.max_uses !== null ? promo.max_uses : '∞'}
</span>
</td>
<td className="p-4">
<button
onClick={() => handleViewRedemptions(promo)}
className="text-neon-400 hover:text-neon-300 transition-colors"
>
{promo.uses_count}
</button>
</td>
<td className="p-4 text-sm text-gray-400">
{promo.valid_until ? formatDate(promo.valid_until) : '—'}
</td>
<td className="p-4">
{isPromoValid(promo) ? (
<span className="px-2 py-1 rounded-full bg-green-500/20 text-green-400 text-xs">
Активен
</span>
) : (
<span className="px-2 py-1 rounded-full bg-red-500/20 text-red-400 text-xs">
Неактивен
</span>
)}
</td>
<td className="p-4 text-sm text-gray-400">
{formatDate(promo.created_at)}
</td>
<td className="p-4">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleToggleActive(promo)}
className={clsx(
'p-1.5 rounded-lg transition-colors',
promo.is_active
? 'bg-green-500/20 text-green-400 hover:bg-green-500/30'
: 'bg-gray-500/20 text-gray-400 hover:bg-gray-500/30'
)}
title={promo.is_active ? 'Деактивировать' : 'Активировать'}
>
{promo.is_active ? <Check className="w-4 h-4" /> : <X className="w-4 h-4" />}
</button>
<button
onClick={() => handleViewRedemptions(promo)}
className="p-1.5 rounded-lg bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 transition-colors"
title="Использования"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => handleEdit(promo)}
className="p-1.5 rounded-lg bg-neon-500/20 text-neon-400 hover:bg-neon-500/30 transition-colors"
title="Редактировать"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(promo)}
className="p-1.5 rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors"
title="Удалить"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</GlassCard>
{/* Create Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-dark-900/80 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="glass rounded-2xl p-6 w-full max-w-md border border-dark-600 shadow-xl">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-white">Создать промокод</h2>
<button
onClick={() => {
setShowCreateModal(false)
resetCreateForm()
}}
className="p-1 text-gray-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
{/* Auto generate toggle */}
<label className="flex items-center gap-3 p-3 bg-dark-700/50 rounded-xl cursor-pointer">
<input
type="checkbox"
checked={autoGenerate}
onChange={(e) => setAutoGenerate(e.target.checked)}
className="rounded border-dark-600 bg-dark-700 text-neon-500 focus:ring-neon-500"
/>
<div>
<p className="text-white font-medium">Автогенерация кода</p>
<p className="text-sm text-gray-400">Создать случайный код</p>
</div>
</label>
{/* Manual code input */}
{!autoGenerate && (
<div>
<label className="block text-sm text-gray-400 mb-1">Код</label>
<Input
placeholder="Введите код (A-Z, 0-9)"
value={createData.code || ''}
onChange={(e) => setCreateData({ ...createData, code: e.target.value.toUpperCase() })}
/>
</div>
)}
{/* Coins amount */}
<div>
<label className="block text-sm text-gray-400 mb-1">Количество монет</label>
<Input
type="number"
min={1}
max={100000}
value={createData.coins_amount}
onChange={(e) => setCreateData({ ...createData, coins_amount: parseInt(e.target.value) || 0 })}
/>
</div>
{/* Max uses */}
<div>
<label className="block text-sm text-gray-400 mb-1">Лимит использований (пусто = безлимит)</label>
<Input
type="number"
min={1}
placeholder="Без ограничений"
value={createData.max_uses || ''}
onChange={(e) => setCreateData({ ...createData, max_uses: e.target.value ? parseInt(e.target.value) : null })}
/>
</div>
{/* Valid until */}
<div>
<label className="block text-sm text-gray-400 mb-1">Действует до (пусто = бессрочно)</label>
<Input
type="datetime-local"
value={createData.valid_until || ''}
onChange={(e) => setCreateData({ ...createData, valid_until: e.target.value || null })}
/>
</div>
<div className="flex gap-3 pt-2">
<NeonButton
onClick={handleCreate}
isLoading={creating}
className="flex-1"
icon={<Plus className="w-4 h-4" />}
>
Создать
</NeonButton>
<NeonButton
variant="ghost"
onClick={() => {
setShowCreateModal(false)
resetCreateForm()
}}
>
Отмена
</NeonButton>
</div>
</div>
</div>
</div>
)}
{/* Edit Modal */}
{editingPromo && (
<div className="fixed inset-0 bg-dark-900/80 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="glass rounded-2xl p-6 w-full max-w-md border border-dark-600 shadow-xl">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-white">Редактировать промокод</h2>
<button
onClick={() => setEditingPromo(null)}
className="p-1 text-gray-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
{/* Code display */}
<div className="p-3 bg-dark-700/50 rounded-xl">
<p className="text-sm text-gray-400 mb-1">Код</p>
<code className="text-yellow-400 font-mono text-lg">{editingPromo.code}</code>
</div>
{/* Active toggle */}
<label className="flex items-center justify-between p-3 bg-dark-700/50 rounded-xl cursor-pointer">
<div>
<p className="text-white font-medium">Активен</p>
<p className="text-sm text-gray-400">Код можно использовать</p>
</div>
<input
type="checkbox"
checked={editData.is_active}
onChange={(e) => setEditData({ ...editData, is_active: e.target.checked })}
className="rounded border-dark-600 bg-dark-700 text-neon-500 focus:ring-neon-500 w-5 h-5"
/>
</label>
{/* Max uses */}
<div>
<label className="block text-sm text-gray-400 mb-1">Лимит использований</label>
<Input
type="number"
min={1}
placeholder="Без ограничений"
value={editData.max_uses || ''}
onChange={(e) => setEditData({ ...editData, max_uses: e.target.value ? parseInt(e.target.value) : null })}
/>
</div>
{/* Valid until */}
<div>
<label className="block text-sm text-gray-400 mb-1">Действует до</label>
<Input
type="datetime-local"
value={editData.valid_until}
onChange={(e) => setEditData({ ...editData, valid_until: e.target.value })}
/>
</div>
<div className="flex gap-3 pt-2">
<NeonButton
onClick={handleSaveEdit}
isLoading={saving}
className="flex-1"
icon={<Check className="w-4 h-4" />}
>
Сохранить
</NeonButton>
<NeonButton
variant="ghost"
onClick={() => setEditingPromo(null)}
>
Отмена
</NeonButton>
</div>
</div>
</div>
</div>
)}
{/* Redemptions Modal */}
{viewingRedemptions && (
<div className="fixed inset-0 bg-dark-900/80 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="glass rounded-2xl p-6 w-full max-w-lg border border-dark-600 shadow-xl max-h-[80vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-bold text-white">Использования промокода</h2>
<p className="text-sm text-gray-400">
<code className="text-yellow-400">{viewingRedemptions.code}</code>
{' • '}
{viewingRedemptions.uses_count} использований
</p>
</div>
<button
onClick={() => {
setViewingRedemptions(null)
setRedemptions([])
}}
className="p-1 text-gray-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto">
{loadingRedemptions ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 text-neon-500 animate-spin" />
</div>
) : redemptions.length === 0 ? (
<div className="text-center py-12">
<Users className="w-10 h-10 text-gray-600 mx-auto mb-3" />
<p className="text-gray-400">Пока никто не использовал этот код</p>
</div>
) : (
<div className="space-y-2">
{redemptions.map((r) => (
<div
key={r.id}
className="flex items-center justify-between p-3 bg-dark-700/50 rounded-xl"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-neon-500/20 flex items-center justify-center">
<Users className="w-4 h-4 text-neon-400" />
</div>
<div>
<p className="text-white font-medium">{r.user.nickname}</p>
<p className="text-xs text-gray-400">{formatDate(r.redeemed_at)}</p>
</div>
</div>
<div className="text-yellow-400 font-medium">
+{r.coins_awarded}
</div>
</div>
))}
</div>
)}
</div>
<div className="pt-4 border-t border-dark-600 mt-4">
<NeonButton
variant="ghost"
onClick={() => {
setViewingRedemptions(null)
setRedemptions([])
}}
className="w-full"
>
Закрыть
</NeonButton>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -5,3 +5,4 @@ export { AdminMarathonsPage } from './AdminMarathonsPage'
export { AdminLogsPage } from './AdminLogsPage' export { AdminLogsPage } from './AdminLogsPage'
export { AdminBroadcastPage } from './AdminBroadcastPage' export { AdminBroadcastPage } from './AdminBroadcastPage'
export { AdminContentPage } from './AdminContentPage' export { AdminContentPage } from './AdminContentPage'
export { AdminPromoCodesPage } from './AdminPromoCodesPage'

View File

@@ -863,3 +863,49 @@ export const ITEM_TYPE_NAMES: Record<ShopItemType, string> = {
background: 'Фон профиля', background: 'Фон профиля',
consumable: 'Расходуемое', 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
}