From e63d6c848905d70aa0d9e7064566c2a8c72954c0 Mon Sep 17 00:00:00 2001 From: Oronemu Date: Thu, 8 Jan 2026 10:02:15 +0700 Subject: [PATCH] Promocode system --- .../alembic/versions/028_add_promo_codes.py | 58 ++ backend/app/api/v1/__init__.py | 3 +- backend/app/api/v1/promo.py | 299 ++++++++ backend/app/api/v1/shop.py | 90 ++- backend/app/models/__init__.py | 3 + backend/app/models/coin_transaction.py | 1 + backend/app/models/promo_code.py | 67 ++ backend/app/schemas/__init__.py | 17 + backend/app/schemas/promo_code.py | 74 ++ frontend/src/App.tsx | 2 + frontend/src/api/index.ts | 1 + frontend/src/api/promo.ts | 34 + frontend/src/api/shop.ts | 7 + frontend/src/pages/PlayPage.tsx | 2 +- frontend/src/pages/ProfilePage.tsx | 60 +- frontend/src/pages/admin/AdminLayout.tsx | 4 +- .../src/pages/admin/AdminPromoCodesPage.tsx | 681 ++++++++++++++++++ frontend/src/pages/admin/index.ts | 1 + frontend/src/types/index.ts | 46 ++ 19 files changed, 1443 insertions(+), 7 deletions(-) create mode 100644 backend/alembic/versions/028_add_promo_codes.py create mode 100644 backend/app/api/v1/promo.py create mode 100644 backend/app/models/promo_code.py create mode 100644 backend/app/schemas/promo_code.py create mode 100644 frontend/src/api/promo.ts create mode 100644 frontend/src/pages/admin/AdminPromoCodesPage.tsx diff --git a/backend/alembic/versions/028_add_promo_codes.py b/backend/alembic/versions/028_add_promo_codes.py new file mode 100644 index 0000000..7ec7a98 --- /dev/null +++ b/backend/alembic/versions/028_add_promo_codes.py @@ -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') diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 359f307..f1b5f71 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -1,6 +1,6 @@ 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") @@ -17,3 +17,4 @@ router.include_router(assignments.router) router.include_router(telegram.router) router.include_router(content.router) router.include_router(shop.router) +router.include_router(promo.router) diff --git a/backend/app/api/v1/promo.py b/backend/app/api/v1/promo.py new file mode 100644 index 0000000..bb539e0 --- /dev/null +++ b/backend/app/api/v1/promo.py @@ -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 + ] diff --git a/backend/app/api/v1/shop.py b/backend/app/api/v1/shop.py index f765d90..98e3a68 100644 --- a/backend/app/api/v1/shop.py +++ b/backend/app/api/v1/shop.py @@ -10,7 +10,7 @@ from app.api.deps import CurrentUser, DbSession, require_participant, require_ad from app.models import ( User, Marathon, Participant, Assignment, AssignmentStatus, ShopItem, UserInventory, CoinTransaction, ShopItemType, - CertificationStatus, + CertificationStatus, Challenge, Game, ) from app.schemas import ( ShopItemResponse, ShopItemCreate, ShopItemUpdate, @@ -19,8 +19,9 @@ from app.schemas import ( EquipItemRequest, EquipItemResponse, CoinTransactionResponse, CoinsBalanceResponse, AdminCoinsRequest, CertificationRequestSchema, CertificationReviewRequest, CertificationStatusResponse, - ConsumablesStatusResponse, MessageResponse, + ConsumablesStatusResponse, MessageResponse, SwapCandidate, ) +from app.schemas.user import UserPublic from app.services.shop import shop_service from app.services.coins import coins_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 === @router.get("/balance", response_model=CoinsBalanceResponse) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 634f620..6ccff8a 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -17,6 +17,7 @@ from app.models.shop import ShopItem, ShopItemType, ItemRarity, ConsumableType from app.models.inventory import UserInventory from app.models.coin_transaction import CoinTransaction, CoinTransactionType from app.models.consumable_usage import ConsumableUsage +from app.models.promo_code import PromoCode, PromoCodeRedemption __all__ = [ "User", @@ -62,4 +63,6 @@ __all__ = [ "CoinTransaction", "CoinTransactionType", "ConsumableUsage", + "PromoCode", + "PromoCodeRedemption", ] diff --git a/backend/app/models/coin_transaction.py b/backend/app/models/coin_transaction.py index beaec6f..2010007 100644 --- a/backend/app/models/coin_transaction.py +++ b/backend/app/models/coin_transaction.py @@ -20,6 +20,7 @@ class CoinTransactionType(str, Enum): REFUND = "refund" ADMIN_GRANT = "admin_grant" ADMIN_DEDUCT = "admin_deduct" + PROMO_CODE = "promo_code" class CoinTransaction(Base): diff --git a/backend/app/models/promo_code.py b/backend/app/models/promo_code.py new file mode 100644 index 0000000..4638d70 --- /dev/null +++ b/backend/app/models/promo_code.py @@ -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") diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index a26844a..24a84db 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -124,6 +124,15 @@ from app.schemas.shop import ( CertificationStatusResponse, ConsumablesStatusResponse, ) +from app.schemas.promo_code import ( + PromoCodeCreate, + PromoCodeUpdate, + PromoCodeResponse, + PromoCodeRedeemRequest, + PromoCodeRedeemResponse, + PromoCodeRedemptionResponse, + PromoCodeRedemptionUser, +) from app.schemas.user import ShopItemPublic __all__ = [ @@ -243,4 +252,12 @@ __all__ = [ "CertificationReviewRequest", "CertificationStatusResponse", "ConsumablesStatusResponse", + # Promo + "PromoCodeCreate", + "PromoCodeUpdate", + "PromoCodeResponse", + "PromoCodeRedeemRequest", + "PromoCodeRedeemResponse", + "PromoCodeRedemptionResponse", + "PromoCodeRedemptionUser", ] diff --git a/backend/app/schemas/promo_code.py b/backend/app/schemas/promo_code.py new file mode 100644 index 0000000..083b6fb --- /dev/null +++ b/backend/app/schemas/promo_code.py @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 79e710b..97c96e5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -37,6 +37,7 @@ import { AdminLogsPage, AdminBroadcastPage, AdminContentPage, + AdminPromoCodesPage, } from '@/pages/admin' // Protected route wrapper @@ -229,6 +230,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 6663f7b..bc680bc 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -10,3 +10,4 @@ export { assignmentsApi } from './assignments' export { usersApi } from './users' export { telegramApi } from './telegram' export { shopApi } from './shop' +export { promoApi } from './promo' diff --git a/frontend/src/api/promo.ts b/frontend/src/api/promo.ts new file mode 100644 index 0000000..d622c33 --- /dev/null +++ b/frontend/src/api/promo.ts @@ -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('/promo/redeem', { code }), + + // Admin endpoints + admin: { + list: (includeInactive = false) => + client.get('/promo/admin/list', { + params: { include_inactive: includeInactive }, + }), + + create: (data: PromoCodeCreate) => + client.post('/promo/admin/create', data), + + update: (id: number, data: PromoCodeUpdate) => + client.put(`/promo/admin/${id}`, data), + + delete: (id: number) => + client.delete<{ message: string }>(`/promo/admin/${id}`), + + getRedemptions: (id: number) => + client.get(`/promo/admin/${id}/redemptions`), + }, +} diff --git a/frontend/src/api/shop.ts b/frontend/src/api/shop.ts index 096aa95..658b4b4 100644 --- a/frontend/src/api/shop.ts +++ b/frontend/src/api/shop.ts @@ -10,6 +10,7 @@ import type { CoinTransaction, ConsumablesStatus, UserCosmetics, + SwapCandidate, } from '@/types' export const shopApi = { @@ -84,6 +85,12 @@ export const shopApi = { return response.data }, + // Получить кандидатов для Copycat (участники с активными заданиями) + getCopycatCandidates: async (marathonId: number): Promise => { + const response = await client.get(`/shop/copycat-candidates/${marathonId}`) + return response.data + }, + // === Монеты === // Получить баланс и последние транзакции diff --git a/frontend/src/pages/PlayPage.tsx b/frontend/src/pages/PlayPage.tsx index 496dfe3..50ed278 100644 --- a/frontend/src/pages/PlayPage.tsx +++ b/frontend/src/pages/PlayPage.tsx @@ -567,7 +567,7 @@ export function PlayPage() { if (!id) return setIsLoadingCopycatCandidates(true) try { - const candidates = await eventsApi.getSwapCandidates(parseInt(id)) + const candidates = await shopApi.getCopycatCandidates(parseInt(id)) setCopycatCandidates(candidates) } catch (error) { console.error('Failed to load copycat candidates:', error) diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 6796270..cfc2f57 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -4,7 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' import { Link } from 'react-router-dom' 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 { useToast } from '@/store/toast' import { @@ -14,7 +14,7 @@ import { User, Camera, Trophy, Target, CheckCircle, Flame, Loader2, MessageCircle, Link2, Link2Off, ExternalLink, Eye, EyeOff, Save, KeyRound, Shield, Bell, Sparkles, - AlertTriangle, FileCheck, Backpack, Edit3 + AlertTriangle, FileCheck, Backpack, Edit3, Gift } from 'lucide-react' import clsx from 'clsx' @@ -289,6 +289,10 @@ export function ProfilePage() { const [notifyModeration, setNotifyModeration] = useState(user?.notify_moderation ?? true) const [notificationUpdating, setNotificationUpdating] = useState(null) + // Promo code state + const [promoCode, setPromoCode] = useState('') + const [isRedeemingPromo, setIsRedeemingPromo] = useState(false) + const fileInputRef = useRef(null) // 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 displayAvatar = avatarBlobUrl || user?.telegram_avatar_url @@ -773,6 +798,37 @@ export function ProfilePage() { )} + {/* Promo Code */} + +
+
+ +
+
+

Промокод

+

Введите код для получения монет

+
+
+
+
+ setPromoCode(e.target.value.toUpperCase())} + maxLength={50} + /> +
+ } + > + Активировать + +
+
+ {/* Telegram */}
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 +}