Add SNS Marketing Campaign feature
- Add cash_cc_balance to User model (withdrawable CC) - Create SnsShareSubmission model for SNS share verification - Add marketing campaign settings to SystemSettings - Add reward_type to ReferralReward model - Create /api/sns-share endpoints for submission and verification - Add referral signup reward logic (10CC on signup) - Create /sns-share user page for SNS sharing - Create /admin/sns-shares management page - Add marketing settings UI to admin settings page - Add SNS Shares menu to admin sidebar 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@ from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import bcrypt
|
||||
from ..database import get_db
|
||||
from ..models import User
|
||||
from ..models import User, SystemSettings, ReferralReward
|
||||
from ..models.user import generate_referral_code
|
||||
from ..schemas import UserCreate, UserUpdate, UserResponse, Token
|
||||
from ..config import get_settings
|
||||
@@ -151,6 +151,32 @@ def register(user_data: UserCreate, db: Session = Depends(get_db)):
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
# 레퍼럴 가입 보상 처리
|
||||
if user.referred_by:
|
||||
# 추천인 찾기
|
||||
referrer = db.query(User).filter(User.referral_code == user.referred_by).first()
|
||||
if referrer:
|
||||
# 설정에서 보상 CC 가져오기
|
||||
sys_settings = db.query(SystemSettings).first()
|
||||
reward_cc = sys_settings.referral_signup_reward_cc if sys_settings else 10.0
|
||||
|
||||
# 추천인에게 CC 지급 (프로모션 CC - 출금 불가)
|
||||
referrer.cc_balance += reward_cc
|
||||
|
||||
# 레퍼럴 보상 기록 생성
|
||||
referral_reward = ReferralReward(
|
||||
referrer_id=referrer.id,
|
||||
referred_user_id=user.id,
|
||||
reward_type="signup",
|
||||
payment_amount=0.0,
|
||||
reward_amount=reward_cc,
|
||||
status="credited",
|
||||
credited_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(referral_reward)
|
||||
db.commit()
|
||||
|
||||
return user
|
||||
|
||||
|
||||
|
||||
277
backend/app/api/sns_share.py
Normal file
277
backend/app/api/sns_share.py
Normal file
@@ -0,0 +1,277 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import User, Car, SnsShareSubmission, SystemSettings
|
||||
from ..schemas.sns_share import (
|
||||
SnsShareSubmit, SnsShareResponse, SnsShareListResponse,
|
||||
SnsShareVerify, SnsShareStats
|
||||
)
|
||||
from .auth import get_current_user, get_current_admin_user
|
||||
|
||||
router = APIRouter(prefix="/sns-share", tags=["SNS Share"])
|
||||
|
||||
|
||||
def get_car_info(car: Car) -> dict:
|
||||
"""차량 정보 추출"""
|
||||
car_name = f"{car.maker_name or ''} {car.model_name or ''} {car.grade_name or ''}".strip()
|
||||
car_image = None
|
||||
if car.images and len(car.images) > 0:
|
||||
car_image = car.images[0].image_url
|
||||
return {"car_name": car_name, "car_image": car_image}
|
||||
|
||||
|
||||
def submission_to_response(submission: SnsShareSubmission) -> SnsShareResponse:
|
||||
"""SnsShareSubmission을 Response로 변환"""
|
||||
car_info = get_car_info(submission.car) if submission.car else {}
|
||||
return SnsShareResponse(
|
||||
id=submission.id,
|
||||
user_id=submission.user_id,
|
||||
car_id=submission.car_id,
|
||||
platform=submission.platform,
|
||||
sns_url=submission.sns_url,
|
||||
status=submission.status,
|
||||
rejected_reason=submission.rejected_reason,
|
||||
reward_cc=submission.reward_cc,
|
||||
rewarded_at=submission.rewarded_at,
|
||||
submitted_at=submission.submitted_at,
|
||||
verified_at=submission.verified_at,
|
||||
verified_by=submission.verified_by,
|
||||
car_name=car_info.get("car_name"),
|
||||
car_image=car_info.get("car_image"),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/submit", response_model=SnsShareResponse)
|
||||
async def submit_sns_share(
|
||||
data: SnsShareSubmit,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""SNS 공유 URL 제출"""
|
||||
# 마케팅 캠페인 활성화 확인
|
||||
settings = db.query(SystemSettings).first()
|
||||
if settings and not settings.marketing_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Marketing campaign is not active"
|
||||
)
|
||||
|
||||
# 캠페인 기간 확인
|
||||
now = datetime.utcnow()
|
||||
if settings:
|
||||
if settings.marketing_start_date and now < settings.marketing_start_date:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Marketing campaign has not started yet"
|
||||
)
|
||||
if settings.marketing_end_date and now > settings.marketing_end_date:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Marketing campaign has ended"
|
||||
)
|
||||
|
||||
# 차량 존재 확인
|
||||
car = db.query(Car).filter(Car.id == data.car_id).first()
|
||||
if not car:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Car not found"
|
||||
)
|
||||
|
||||
# 플랫폼 검증
|
||||
valid_platforms = ["twitter", "instagram", "facebook"]
|
||||
if data.platform.lower() not in valid_platforms:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid platform. Must be one of: {', '.join(valid_platforms)}"
|
||||
)
|
||||
|
||||
# 중복 제출 확인 (같은 차량, 같은 URL)
|
||||
existing = db.query(SnsShareSubmission).filter(
|
||||
SnsShareSubmission.user_id == current_user.id,
|
||||
SnsShareSubmission.car_id == data.car_id,
|
||||
SnsShareSubmission.sns_url == data.sns_url
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="You have already submitted this URL for this car"
|
||||
)
|
||||
|
||||
# 보상 CC 설정
|
||||
reward_cc = settings.sns_share_reward_cc if settings else 3.0
|
||||
|
||||
# 제출 생성
|
||||
submission = SnsShareSubmission(
|
||||
user_id=current_user.id,
|
||||
car_id=data.car_id,
|
||||
platform=data.platform.lower(),
|
||||
sns_url=data.sns_url,
|
||||
status="pending",
|
||||
reward_cc=reward_cc,
|
||||
)
|
||||
db.add(submission)
|
||||
db.commit()
|
||||
db.refresh(submission)
|
||||
|
||||
return submission_to_response(submission)
|
||||
|
||||
|
||||
@router.get("/my-submissions", response_model=SnsShareListResponse)
|
||||
async def get_my_submissions(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""내 SNS 공유 제출 목록"""
|
||||
submissions = db.query(SnsShareSubmission).filter(
|
||||
SnsShareSubmission.user_id == current_user.id
|
||||
).order_by(SnsShareSubmission.submitted_at.desc()).all()
|
||||
|
||||
pending_count = len([s for s in submissions if s.status == "pending"])
|
||||
approved_count = len([s for s in submissions if s.status == "approved"])
|
||||
rejected_count = len([s for s in submissions if s.status == "rejected"])
|
||||
|
||||
return SnsShareListResponse(
|
||||
submissions=[submission_to_response(s) for s in submissions],
|
||||
total=len(submissions),
|
||||
pending_count=pending_count,
|
||||
approved_count=approved_count,
|
||||
rejected_count=rejected_count,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/list", response_model=SnsShareListResponse)
|
||||
async def admin_get_submissions(
|
||||
status_filter: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""관리자: 전체 SNS 공유 제출 목록"""
|
||||
query = db.query(SnsShareSubmission)
|
||||
|
||||
if status_filter:
|
||||
query = query.filter(SnsShareSubmission.status == status_filter)
|
||||
|
||||
total = query.count()
|
||||
submissions = query.order_by(SnsShareSubmission.submitted_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
# 전체 통계
|
||||
pending_count = db.query(SnsShareSubmission).filter(SnsShareSubmission.status == "pending").count()
|
||||
approved_count = db.query(SnsShareSubmission).filter(SnsShareSubmission.status == "approved").count()
|
||||
rejected_count = db.query(SnsShareSubmission).filter(SnsShareSubmission.status == "rejected").count()
|
||||
|
||||
return SnsShareListResponse(
|
||||
submissions=[submission_to_response(s) for s in submissions],
|
||||
total=total,
|
||||
pending_count=pending_count,
|
||||
approved_count=approved_count,
|
||||
rejected_count=rejected_count,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/stats", response_model=SnsShareStats)
|
||||
async def admin_get_stats(
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""관리자: SNS 공유 통계"""
|
||||
total = db.query(SnsShareSubmission).count()
|
||||
pending = db.query(SnsShareSubmission).filter(SnsShareSubmission.status == "pending").count()
|
||||
approved = db.query(SnsShareSubmission).filter(SnsShareSubmission.status == "approved").count()
|
||||
rejected = db.query(SnsShareSubmission).filter(SnsShareSubmission.status == "rejected").count()
|
||||
|
||||
total_rewarded = db.query(func.sum(SnsShareSubmission.reward_cc)).filter(
|
||||
SnsShareSubmission.status == "approved"
|
||||
).scalar() or 0.0
|
||||
|
||||
return SnsShareStats(
|
||||
total_submissions=total,
|
||||
pending_submissions=pending,
|
||||
approved_submissions=approved,
|
||||
rejected_submissions=rejected,
|
||||
total_rewarded_cc=total_rewarded,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/admin/{submission_id}/verify", response_model=SnsShareResponse)
|
||||
async def admin_verify_submission(
|
||||
submission_id: int,
|
||||
data: SnsShareVerify,
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""관리자: SNS 공유 검증 (승인/거부)"""
|
||||
submission = db.query(SnsShareSubmission).filter(
|
||||
SnsShareSubmission.id == submission_id
|
||||
).first()
|
||||
|
||||
if not submission:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Submission not found"
|
||||
)
|
||||
|
||||
if submission.status != "pending":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Submission has already been verified"
|
||||
)
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
if data.approved:
|
||||
# 승인: CC 지급
|
||||
submission.status = "approved"
|
||||
submission.verified_at = now
|
||||
submission.verified_by = admin.id
|
||||
submission.rewarded_at = now
|
||||
|
||||
# 사용자 CC 잔액 업데이트 (프로모션 CC - 출금 불가)
|
||||
user = db.query(User).filter(User.id == submission.user_id).first()
|
||||
if user:
|
||||
user.cc_balance += submission.reward_cc
|
||||
else:
|
||||
# 거부
|
||||
submission.status = "rejected"
|
||||
submission.verified_at = now
|
||||
submission.verified_by = admin.id
|
||||
submission.rejected_reason = data.rejected_reason
|
||||
|
||||
db.commit()
|
||||
db.refresh(submission)
|
||||
|
||||
return submission_to_response(submission)
|
||||
|
||||
|
||||
@router.get("/campaign-status")
|
||||
async def get_campaign_status(db: Session = Depends(get_db)):
|
||||
"""마케팅 캠페인 상태 조회 (공개)"""
|
||||
settings = db.query(SystemSettings).first()
|
||||
|
||||
if not settings:
|
||||
return {
|
||||
"enabled": False,
|
||||
"message": "Campaign not configured"
|
||||
}
|
||||
|
||||
now = datetime.utcnow()
|
||||
is_active = settings.marketing_enabled
|
||||
|
||||
if settings.marketing_start_date and now < settings.marketing_start_date:
|
||||
is_active = False
|
||||
if settings.marketing_end_date and now > settings.marketing_end_date:
|
||||
is_active = False
|
||||
|
||||
return {
|
||||
"enabled": is_active,
|
||||
"start_date": settings.marketing_start_date,
|
||||
"end_date": settings.marketing_end_date,
|
||||
"sns_share_reward_cc": settings.sns_share_reward_cc,
|
||||
"referral_signup_reward_cc": settings.referral_signup_reward_cc,
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import asyncio
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from .database import engine, Base, SessionLocal
|
||||
from .api import cars, auth, inquiries, hero_banners, carmodoo, translations, cc, settings, vehicle_requests, dealer, vehicle_share, withdrawal, referral, notification, dashboard, push, exchange_rate, verification, visitor
|
||||
from .api import cars, auth, inquiries, hero_banners, carmodoo, translations, cc, settings, vehicle_requests, dealer, vehicle_share, withdrawal, referral, notification, dashboard, push, exchange_rate, verification, visitor, sns_share
|
||||
from .config import get_settings
|
||||
from .services.exchange_rate_service import update_exchange_rates
|
||||
from .services.visitor_service import aggregate_daily_stats, cleanup_old_visitor_logs
|
||||
@@ -201,6 +201,7 @@ app.include_router(push.router, prefix="/api")
|
||||
app.include_router(exchange_rate.router)
|
||||
app.include_router(verification.router, prefix="/api")
|
||||
app.include_router(visitor.router, prefix="/api")
|
||||
app.include_router(sns_share.router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@@ -11,6 +11,7 @@ from .vehicle_share import VehicleShare, ShareReward
|
||||
from .withdrawal import WithdrawalRequest
|
||||
from .referral import ReferralReward
|
||||
from .notification import Notification
|
||||
from .sns_share import SnsShareSubmission
|
||||
from .push_subscription import PushSubscription, UserNotificationPreference
|
||||
from .performance_check import CarPerformanceCheck
|
||||
from .car_specification import CarSpecification
|
||||
@@ -52,6 +53,7 @@ __all__ = [
|
||||
"WithdrawalRequest",
|
||||
"ReferralReward",
|
||||
"Notification",
|
||||
"SnsShareSubmission",
|
||||
"PushSubscription",
|
||||
"UserNotificationPreference",
|
||||
"ExchangeRate",
|
||||
|
||||
@@ -16,10 +16,13 @@ class ReferralReward(Base):
|
||||
# 피추천인 (추천받아 가입한 사람)
|
||||
referred_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
|
||||
# 결제 금액 (피추천인이 충전한 금액 USD)
|
||||
payment_amount = Column(Float, nullable=False)
|
||||
# 보상 유형: "signup" (가입 시), "payment" (결제 시)
|
||||
reward_type = Column(String(20), default="payment")
|
||||
|
||||
# 보상 금액 (결제 금액의 X%)
|
||||
# 결제 금액 (피추천인이 충전한 금액 USD, signup인 경우 0)
|
||||
payment_amount = Column(Float, default=0.0)
|
||||
|
||||
# 보상 금액 (결제 금액의 X% 또는 고정 CC)
|
||||
reward_amount = Column(Float, nullable=False)
|
||||
|
||||
# 보상 상태: pending(대기), credited(적립), withdrawn(출금)
|
||||
|
||||
@@ -47,6 +47,22 @@ class SystemSettings(Base):
|
||||
exchange_rate_weight_rub = Column(Float, default=0.0) # RUB (러시아 루블) 가중치
|
||||
exchange_rate_weight_cny = Column(Float, default=0.0) # CNY (중국 위안) 가중치
|
||||
|
||||
# 마케팅 캠페인 설정
|
||||
marketing_enabled = Column(Boolean, default=False) # 마케팅 캠페인 활성화
|
||||
marketing_start_date = Column(DateTime(timezone=True), nullable=True) # 캠페인 시작일
|
||||
marketing_end_date = Column(DateTime(timezone=True), nullable=True) # 캠페인 종료일
|
||||
|
||||
# SNS 공유 보상 설정
|
||||
sns_share_reward_cc = Column(Float, default=3.0) # SNS 공유 보상 CC (기본 3CC)
|
||||
referral_signup_reward_cc = Column(Float, default=10.0) # 레퍼럴 가입 보상 CC (기본 10CC)
|
||||
|
||||
# 이벤트 CC 유효기간 (개월)
|
||||
event_cc_validity_months = Column(Integer, default=6) # 이벤트 CC 유효기간 (기본 6개월)
|
||||
|
||||
# 출금 설정
|
||||
withdrawal_enabled = Column(Boolean, default=True) # 출금 기능 활성화
|
||||
min_withdrawal_usd = Column(Float, default=10.0) # 최소 출금 금액 (USD)
|
||||
|
||||
# 타임스탬프
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
35
backend/app/models/sns_share.py
Normal file
35
backend/app/models/sns_share.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class SnsShareSubmission(Base):
|
||||
"""SNS 공유 제출 및 검증 모델"""
|
||||
__tablename__ = "sns_share_submissions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
car_id = Column(Integer, ForeignKey("cars.id"), nullable=False)
|
||||
|
||||
# SNS 정보
|
||||
platform = Column(String(20), nullable=False) # twitter, instagram, facebook
|
||||
sns_url = Column(String(500), nullable=False) # 공유 게시물 URL
|
||||
|
||||
# 상태
|
||||
status = Column(String(20), default="pending") # pending, approved, rejected
|
||||
rejected_reason = Column(Text, nullable=True) # 거부 사유
|
||||
|
||||
# CC 보상
|
||||
reward_cc = Column(Float, default=3.0) # 기본 3CC 보상
|
||||
rewarded_at = Column(DateTime(timezone=True), nullable=True) # 보상 지급 시각
|
||||
|
||||
# 타임스탬프
|
||||
submitted_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
verified_at = Column(DateTime(timezone=True), nullable=True) # 검증 시각
|
||||
verified_by = Column(Integer, ForeignKey("users.id"), nullable=True) # 검증한 관리자
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", foreign_keys=[user_id], backref="sns_submissions")
|
||||
car = relationship("Car", backref="sns_shares")
|
||||
verifier = relationship("User", foreign_keys=[verified_by])
|
||||
@@ -24,7 +24,8 @@ class User(Base):
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_admin = Column(Boolean, default=False)
|
||||
is_dealer = Column(Boolean, default=False) # Dealer status
|
||||
cc_balance = Column(Float, default=3.0) # CC coin balance, 3 free on signup
|
||||
cc_balance = Column(Float, default=3.0) # 프로모션 CC (출금 불가) - 가입보너스, SNS공유, 레퍼럴
|
||||
cash_cc_balance = Column(Float, default=0.0) # 현금성 CC (출금 가능) - 딜러수수료 88%
|
||||
referral_code = Column(String(8), unique=True, index=True) # Unique referral code for sharing
|
||||
referred_by = Column(String(8), nullable=True) # Referral code of the user who referred this user
|
||||
|
||||
|
||||
@@ -47,6 +47,10 @@ from .notification import (
|
||||
NotificationCreate, NotificationResponse,
|
||||
NotificationListResponse, NotificationMarkRead,
|
||||
)
|
||||
from .sns_share import (
|
||||
SnsShareSubmit, SnsShareResponse, SnsShareListResponse,
|
||||
SnsShareVerify, SnsShareStats,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CarMakerCreate", "CarMakerResponse",
|
||||
@@ -78,4 +82,6 @@ __all__ = [
|
||||
"ReferralSettingsResponse", "ReferralSettingsUpdate",
|
||||
"NotificationCreate", "NotificationResponse",
|
||||
"NotificationListResponse", "NotificationMarkRead",
|
||||
"SnsShareSubmit", "SnsShareResponse", "SnsShareListResponse",
|
||||
"SnsShareVerify", "SnsShareStats",
|
||||
]
|
||||
|
||||
@@ -22,6 +22,16 @@ class SystemSettingsUpdate(BaseModel):
|
||||
referral_reward_percent: Optional[float] = None
|
||||
referral_reward_type: Optional[str] = None
|
||||
|
||||
# 마케팅 캠페인 설정
|
||||
marketing_enabled: Optional[bool] = None
|
||||
marketing_start_date: Optional[datetime] = None
|
||||
marketing_end_date: Optional[datetime] = None
|
||||
sns_share_reward_cc: Optional[float] = None
|
||||
referral_signup_reward_cc: Optional[float] = None
|
||||
event_cc_validity_months: Optional[int] = None
|
||||
withdrawal_enabled: Optional[bool] = None
|
||||
min_withdrawal_usd: Optional[float] = None
|
||||
|
||||
|
||||
class SystemSettingsResponse(BaseModel):
|
||||
"""시스템 설정 응답 스키마"""
|
||||
@@ -42,6 +52,17 @@ class SystemSettingsResponse(BaseModel):
|
||||
referral_reward_enabled: bool = True
|
||||
referral_reward_percent: float = 10.0
|
||||
referral_reward_type: str = "one_time"
|
||||
|
||||
# 마케팅 캠페인 설정
|
||||
marketing_enabled: bool = False
|
||||
marketing_start_date: Optional[datetime] = None
|
||||
marketing_end_date: Optional[datetime] = None
|
||||
sns_share_reward_cc: float = 3.0
|
||||
referral_signup_reward_cc: float = 10.0
|
||||
event_cc_validity_months: int = 6
|
||||
withdrawal_enabled: bool = True
|
||||
min_withdrawal_usd: float = 10.0
|
||||
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
57
backend/app/schemas/sns_share.py
Normal file
57
backend/app/schemas/sns_share.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from pydantic import BaseModel, HttpUrl
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class SnsShareSubmit(BaseModel):
|
||||
"""SNS 공유 제출 요청"""
|
||||
car_id: int
|
||||
platform: str # twitter, instagram, facebook
|
||||
sns_url: str # 공유 게시물 URL
|
||||
|
||||
|
||||
class SnsShareResponse(BaseModel):
|
||||
"""SNS 공유 제출 응답"""
|
||||
id: int
|
||||
user_id: int
|
||||
car_id: int
|
||||
platform: str
|
||||
sns_url: str
|
||||
status: str
|
||||
rejected_reason: Optional[str] = None
|
||||
reward_cc: float
|
||||
rewarded_at: Optional[datetime] = None
|
||||
submitted_at: datetime
|
||||
verified_at: Optional[datetime] = None
|
||||
verified_by: Optional[int] = None
|
||||
|
||||
# Car info for display
|
||||
car_name: Optional[str] = None
|
||||
car_image: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SnsShareListResponse(BaseModel):
|
||||
"""SNS 공유 목록 응답"""
|
||||
submissions: List[SnsShareResponse]
|
||||
total: int
|
||||
pending_count: int
|
||||
approved_count: int
|
||||
rejected_count: int
|
||||
|
||||
|
||||
class SnsShareVerify(BaseModel):
|
||||
"""관리자 검증 요청"""
|
||||
approved: bool
|
||||
rejected_reason: Optional[str] = None
|
||||
|
||||
|
||||
class SnsShareStats(BaseModel):
|
||||
"""SNS 공유 통계"""
|
||||
total_submissions: int
|
||||
pending_submissions: int
|
||||
approved_submissions: int
|
||||
rejected_submissions: int
|
||||
total_rewarded_cc: float
|
||||
45
backend/migrations/marketing_campaign_v1.sql
Normal file
45
backend/migrations/marketing_campaign_v1.sql
Normal file
@@ -0,0 +1,45 @@
|
||||
-- Marketing Campaign Migration V1
|
||||
-- Apply to staging/production PostgreSQL databases
|
||||
--
|
||||
-- Instructions:
|
||||
-- 1. Connect to staging: psql -U autonet -d autonet_staging
|
||||
-- 2. Run: \i /path/to/marketing_campaign_v1.sql
|
||||
--
|
||||
|
||||
-- 1. Add cash_cc_balance to users table
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS cash_cc_balance FLOAT DEFAULT 0.0;
|
||||
|
||||
-- 2. Create sns_share_submissions table
|
||||
CREATE TABLE IF NOT EXISTS sns_share_submissions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
car_id INTEGER NOT NULL REFERENCES cars(id),
|
||||
platform VARCHAR(20) NOT NULL,
|
||||
sns_url VARCHAR(500) NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
rejected_reason TEXT,
|
||||
reward_cc FLOAT DEFAULT 3.0,
|
||||
rewarded_at TIMESTAMP WITH TIME ZONE,
|
||||
submitted_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
verified_at TIMESTAMP WITH TIME ZONE,
|
||||
verified_by INTEGER REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_sns_share_submissions_user_id ON sns_share_submissions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_sns_share_submissions_status ON sns_share_submissions(status);
|
||||
|
||||
-- 3. Add marketing settings to system_settings table
|
||||
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS marketing_enabled BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS marketing_start_date TIMESTAMP WITH TIME ZONE;
|
||||
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS marketing_end_date TIMESTAMP WITH TIME ZONE;
|
||||
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS sns_share_reward_cc FLOAT DEFAULT 3.0;
|
||||
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS referral_signup_reward_cc FLOAT DEFAULT 10.0;
|
||||
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS event_cc_validity_months INTEGER DEFAULT 6;
|
||||
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS withdrawal_enabled BOOLEAN DEFAULT TRUE;
|
||||
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS min_withdrawal_usd FLOAT DEFAULT 10.0;
|
||||
|
||||
-- 4. Add reward_type to referral_rewards table
|
||||
ALTER TABLE referral_rewards ADD COLUMN IF NOT EXISTS reward_type VARCHAR(20) DEFAULT 'payment';
|
||||
|
||||
-- Done
|
||||
SELECT 'Marketing Campaign V1 migration completed!' AS status;
|
||||
@@ -14,6 +14,7 @@ const menuItems = [
|
||||
{ href: '/admin/dealers', label: 'Dealers', icon: '🤝' },
|
||||
{ href: '/admin/payments', label: 'Payments', icon: '💳' },
|
||||
{ href: '/admin/withdrawals', label: 'Withdrawals', icon: '💸' },
|
||||
{ href: '/admin/sns-shares', label: 'SNS Shares', icon: '📢' },
|
||||
{ href: '/admin/notifications', label: 'Notifications', icon: '🔔' },
|
||||
{ href: '/admin/translations', label: 'Translations', icon: '🌐' },
|
||||
{ href: '/admin/dealer-translations', label: 'Dealer Descriptions', icon: '📝' },
|
||||
|
||||
@@ -24,6 +24,15 @@ interface SystemSettings {
|
||||
exchange_rate_weight_mnt: number;
|
||||
exchange_rate_weight_rub: number;
|
||||
exchange_rate_weight_cny: number;
|
||||
// Marketing campaign settings
|
||||
marketing_enabled: boolean;
|
||||
marketing_start_date: string | null;
|
||||
marketing_end_date: string | null;
|
||||
sns_share_reward_cc: number;
|
||||
referral_signup_reward_cc: number;
|
||||
event_cc_validity_months: number;
|
||||
withdrawal_enabled: boolean;
|
||||
min_withdrawal_usd: number;
|
||||
}
|
||||
|
||||
interface ExchangeRateWeights {
|
||||
@@ -66,6 +75,15 @@ export default function SettingsPage() {
|
||||
referral_reward_enabled: true,
|
||||
referral_reward_percent: 10.0,
|
||||
referral_reward_type: 'one_time',
|
||||
// Marketing campaign
|
||||
marketing_enabled: false,
|
||||
marketing_start_date: '',
|
||||
marketing_end_date: '',
|
||||
sns_share_reward_cc: 3.0,
|
||||
referral_signup_reward_cc: 10.0,
|
||||
event_cc_validity_months: 6,
|
||||
withdrawal_enabled: true,
|
||||
min_withdrawal_usd: 10.0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -96,6 +114,15 @@ export default function SettingsPage() {
|
||||
referral_reward_enabled: data.referral_reward_enabled ?? true,
|
||||
referral_reward_percent: data.referral_reward_percent ?? 10.0,
|
||||
referral_reward_type: data.referral_reward_type || 'one_time',
|
||||
// Marketing campaign
|
||||
marketing_enabled: data.marketing_enabled ?? false,
|
||||
marketing_start_date: data.marketing_start_date ? data.marketing_start_date.split('T')[0] : '',
|
||||
marketing_end_date: data.marketing_end_date ? data.marketing_end_date.split('T')[0] : '',
|
||||
sns_share_reward_cc: data.sns_share_reward_cc ?? 3.0,
|
||||
referral_signup_reward_cc: data.referral_signup_reward_cc ?? 10.0,
|
||||
event_cc_validity_months: data.event_cc_validity_months ?? 6,
|
||||
withdrawal_enabled: data.withdrawal_enabled ?? true,
|
||||
min_withdrawal_usd: data.min_withdrawal_usd ?? 10.0,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -531,6 +558,152 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Marketing Campaign Settings */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<span>📢 Marketing Campaign Settings</span>
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Campaign Toggle */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.marketing_enabled}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, marketing_enabled: e.target.checked }))}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-green-600"></div>
|
||||
</label>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700">Enable Marketing Campaign</span>
|
||||
<p className="text-sm text-gray-500">SNS 공유 및 레퍼럴 보상 캠페인 활성화</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Campaign Period */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Campaign Start Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.marketing_start_date}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, marketing_start_date: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Campaign End Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.marketing_end_date}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, marketing_end_date: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reward Settings */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
SNS Share Reward (CC)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.5"
|
||||
value={formData.sns_share_reward_cc}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, sns_share_reward_cc: parseFloat(e.target.value) || 3 }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">SNS 공유 승인 시 지급되는 CC</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Referral Signup Reward (CC)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.5"
|
||||
value={formData.referral_signup_reward_cc}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, referral_signup_reward_cc: parseFloat(e.target.value) || 10 }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">친구가 가입 시 추천인에게 지급되는 CC</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Event CC Validity (Months)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="24"
|
||||
value={formData.event_cc_validity_months}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, event_cc_validity_months: parseInt(e.target.value) || 6 }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">이벤트 CC 유효기간</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Withdrawal Settings */}
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<h3 className="font-medium text-gray-700 mb-3">Withdrawal Settings</h3>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.withdrawal_enabled}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, withdrawal_enabled: e.target.checked }))}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
|
||||
</label>
|
||||
<span className="text-sm font-medium text-gray-700">Enable Withdrawals</span>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xs">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Minimum Withdrawal (USD)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="1000"
|
||||
value={formData.min_withdrawal_usd}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, min_withdrawal_usd: parseFloat(e.target.value) || 10 }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Campaign Preview */}
|
||||
<div className={`p-4 rounded-lg ${formData.marketing_enabled ? 'bg-green-50' : 'bg-gray-50'}`}>
|
||||
<h3 className={`font-medium mb-2 ${formData.marketing_enabled ? 'text-green-800' : 'text-gray-700'}`}>
|
||||
Campaign Status: {formData.marketing_enabled ? 'ACTIVE' : 'INACTIVE'}
|
||||
</h3>
|
||||
<div className={`text-sm space-y-1 ${formData.marketing_enabled ? 'text-green-700' : 'text-gray-600'}`}>
|
||||
<p>SNS Share: {formData.sns_share_reward_cc} CC per approved share</p>
|
||||
<p>Referral Signup: {formData.referral_signup_reward_cc} CC per new signup</p>
|
||||
<p>Period: {formData.marketing_start_date || 'Not set'} ~ {formData.marketing_end_date || 'Not set'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
|
||||
281
frontend/src/app/admin/sns-shares/page.tsx
Normal file
281
frontend/src/app/admin/sns-shares/page.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { snsShareApi, SnsShareSubmission, SnsShareStats } from '@/lib/api';
|
||||
|
||||
const PLATFORMS: Record<string, { name: string; icon: string }> = {
|
||||
twitter: { name: 'Twitter/X', icon: '𝕏' },
|
||||
instagram: { name: 'Instagram', icon: '📷' },
|
||||
facebook: { name: 'Facebook', icon: '📘' },
|
||||
};
|
||||
|
||||
export default function AdminSnsSharesPage() {
|
||||
const { user } = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submissions, setSubmissions] = useState<SnsShareSubmission[]>([]);
|
||||
const [stats, setStats] = useState<SnsShareStats | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('pending');
|
||||
const [selectedSubmission, setSelectedSubmission] = useState<SnsShareSubmission | null>(null);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.is_admin) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
loadData();
|
||||
}, [user, router, statusFilter]);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [submissionsData, statsData] = await Promise.all([
|
||||
snsShareApi.getAdminList(statusFilter || undefined),
|
||||
snsShareApi.getAdminStats(),
|
||||
]);
|
||||
setSubmissions(submissionsData.submissions);
|
||||
setStats(statsData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify = async (approved: boolean) => {
|
||||
if (!selectedSubmission) return;
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
await snsShareApi.verify(
|
||||
selectedSubmission.id,
|
||||
approved,
|
||||
approved ? undefined : rejectReason
|
||||
);
|
||||
setSelectedSubmission(null);
|
||||
setRejectReason('');
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to verify:', error);
|
||||
alert('Failed to verify submission');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const styles: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
approved: 'bg-green-100 text-green-800',
|
||||
rejected: 'bg-red-100 text-red-800',
|
||||
};
|
||||
return (
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${styles[status] || 'bg-gray-100'}`}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 py-8">
|
||||
<div className="container mx-auto px-4">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">SNS Share Management</h1>
|
||||
<p className="text-gray-600">Review and verify SNS share submissions</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-sm text-gray-500">Total Submissions</div>
|
||||
<div className="text-2xl font-bold text-gray-800">{stats.total_submissions}</div>
|
||||
</div>
|
||||
<div className="bg-yellow-50 rounded-lg shadow p-4">
|
||||
<div className="text-sm text-yellow-600">Pending</div>
|
||||
<div className="text-2xl font-bold text-yellow-700">{stats.pending_submissions}</div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg shadow p-4">
|
||||
<div className="text-sm text-green-600">Approved</div>
|
||||
<div className="text-2xl font-bold text-green-700">{stats.approved_submissions}</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 rounded-lg shadow p-4">
|
||||
<div className="text-sm text-blue-600">Total Rewarded</div>
|
||||
<div className="text-2xl font-bold text-blue-700">{stats.total_rewarded_cc} CC</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div className="bg-white rounded-lg shadow mb-6">
|
||||
<div className="flex border-b">
|
||||
{['pending', 'approved', 'rejected', ''].map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => setStatusFilter(status)}
|
||||
className={`px-6 py-3 font-medium ${
|
||||
statusFilter === status
|
||||
? 'text-primary-600 border-b-2 border-primary-600'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{status || 'All'}
|
||||
{status === 'pending' && stats && stats.pending_submissions > 0 && (
|
||||
<span className="ml-2 bg-yellow-500 text-white text-xs px-2 py-0.5 rounded-full">
|
||||
{stats.pending_submissions}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submissions Table */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Platform</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Car</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">User</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Submitted</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Status</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-500">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{submissions.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
|
||||
No submissions found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
submissions.map((sub) => (
|
||||
<tr key={sub.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">{PLATFORMS[sub.platform]?.icon || '🔗'}</span>
|
||||
<span className="text-sm">{PLATFORMS[sub.platform]?.name || sub.platform}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-sm font-medium">{sub.car_name || `Car #${sub.car_id}`}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">User #{sub.user_id}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{new Date(sub.submitted_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3">{getStatusBadge(sub.status)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-2">
|
||||
<a
|
||||
href={sub.sns_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-sm"
|
||||
>
|
||||
View Post
|
||||
</a>
|
||||
{sub.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => setSelectedSubmission(sub)}
|
||||
className="text-primary-600 hover:underline text-sm"
|
||||
>
|
||||
Verify
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verification Modal */}
|
||||
{selectedSubmission && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full p-6">
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-4">Verify Submission</h3>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-2xl">{PLATFORMS[selectedSubmission.platform]?.icon}</span>
|
||||
<div>
|
||||
<div className="font-medium">{selectedSubmission.car_name || 'Car'}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
User #{selectedSubmission.user_id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={selectedSubmission.sns_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-sm break-all"
|
||||
>
|
||||
{selectedSubmission.sns_url}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Reject Reason (if rejecting)
|
||||
</label>
|
||||
<textarea
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
placeholder="Enter reason for rejection..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 resize-none"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedSubmission(null);
|
||||
setRejectReason('');
|
||||
}}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleVerify(false)}
|
||||
disabled={processing}
|
||||
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{processing ? 'Processing...' : 'Reject'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleVerify(true)}
|
||||
disabled={processing}
|
||||
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{processing ? 'Processing...' : `Approve (+${selectedSubmission.reward_cc} CC)`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
373
frontend/src/app/sns-share/page.tsx
Normal file
373
frontend/src/app/sns-share/page.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { snsShareApi, SnsShareSubmission, CampaignStatus, vehicleRequestsApi } from '@/lib/api';
|
||||
|
||||
const PLATFORMS = [
|
||||
{ id: 'twitter', name: 'Twitter/X', icon: '𝕏' },
|
||||
{ id: 'instagram', name: 'Instagram', icon: '📷' },
|
||||
{ id: 'facebook', name: 'Facebook', icon: '📘' },
|
||||
];
|
||||
|
||||
export default function SnsSharePage() {
|
||||
const { t, language } = useTranslation();
|
||||
const { user, token } = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [campaign, setCampaign] = useState<CampaignStatus | null>(null);
|
||||
const [submissions, setSubmissions] = useState<SnsShareSubmission[]>([]);
|
||||
const [approvedCars, setApprovedCars] = useState<any[]>([]);
|
||||
|
||||
// Form state
|
||||
const [selectedCarId, setSelectedCarId] = useState<number | null>(null);
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<string>('');
|
||||
const [snsUrl, setSnsUrl] = useState('');
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
loadData();
|
||||
}, [user, router]);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Load campaign status
|
||||
const campaignData = await snsShareApi.getCampaignStatus();
|
||||
setCampaign(campaignData);
|
||||
|
||||
// Load my submissions
|
||||
const submissionsData = await snsShareApi.getMySubmissions();
|
||||
setSubmissions(submissionsData.submissions);
|
||||
|
||||
// Load my approved cars from vehicle requests
|
||||
try {
|
||||
const vehicleData = await vehicleRequestsApi.getMyVehicles();
|
||||
const cars: any[] = [];
|
||||
vehicleData.vehicle_requests?.forEach((req: any) => {
|
||||
req.approved_vehicles?.forEach((v: any) => {
|
||||
cars.push({
|
||||
id: v.car_id || v.id,
|
||||
name: v.car_data?.car_name || v.car_data?.full_name || 'Unknown Car',
|
||||
image: v.car_data?.images?.[0] || null,
|
||||
});
|
||||
});
|
||||
});
|
||||
setApprovedCars(cars);
|
||||
} catch (e) {
|
||||
console.log('No approved cars found');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!selectedCarId || !selectedPlatform || !snsUrl) {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: language === 'ko' ? '모든 필드를 입력해주세요.' : 'Please fill in all fields.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic URL validation
|
||||
if (!snsUrl.startsWith('http://') && !snsUrl.startsWith('https://')) {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: language === 'ko' ? '올바른 URL을 입력해주세요.' : 'Please enter a valid URL.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
await snsShareApi.submit(selectedCarId, selectedPlatform, snsUrl);
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: language === 'ko'
|
||||
? '제출되었습니다! 24시간 내에 검증 후 CC가 지급됩니다.'
|
||||
: 'Submitted! You will receive CC after verification within 24 hours.',
|
||||
});
|
||||
// Reset form
|
||||
setSelectedCarId(null);
|
||||
setSelectedPlatform('');
|
||||
setSnsUrl('');
|
||||
// Reload submissions
|
||||
const submissionsData = await snsShareApi.getMySubmissions();
|
||||
setSubmissions(submissionsData.submissions);
|
||||
} catch (error: any) {
|
||||
const detail = error.response?.data?.detail || (language === 'ko' ? '제출 실패' : 'Submission failed');
|
||||
setMessage({ type: 'error', text: detail });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const styles: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
approved: 'bg-green-100 text-green-800',
|
||||
rejected: 'bg-red-100 text-red-800',
|
||||
};
|
||||
const labels: Record<string, Record<string, string>> = {
|
||||
pending: { ko: '검증 대기', en: 'Pending' },
|
||||
approved: { ko: '승인됨', en: 'Approved' },
|
||||
rejected: { ko: '거부됨', en: 'Rejected' },
|
||||
};
|
||||
return (
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${styles[status] || 'bg-gray-100'}`}>
|
||||
{labels[status]?.[language === 'ko' ? 'ko' : 'en'] || status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="container mx-auto px-4 max-w-4xl">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">
|
||||
{language === 'ko' ? 'SNS 공유하고 CC 받기' : 'Share on SNS and Earn CC'}
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
{language === 'ko'
|
||||
? '차량을 SNS에 공유하고 CC 보상을 받으세요!'
|
||||
: 'Share vehicles on social media and get CC rewards!'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Campaign Status */}
|
||||
{!campaign?.enabled ? (
|
||||
<div className="bg-gray-100 border border-gray-300 rounded-xl p-6 text-center mb-8">
|
||||
<div className="text-4xl mb-4">🚫</div>
|
||||
<h2 className="text-xl font-semibold text-gray-700 mb-2">
|
||||
{language === 'ko' ? '캠페인이 활성화되지 않았습니다' : 'Campaign is not active'}
|
||||
</h2>
|
||||
<p className="text-gray-500">
|
||||
{language === 'ko'
|
||||
? '마케팅 캠페인 기간에 다시 방문해주세요.'
|
||||
: 'Please visit again during the marketing campaign period.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Campaign Info Card */}
|
||||
<div className="bg-gradient-to-r from-primary-500 to-primary-600 text-white rounded-xl p-6 mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold mb-1">
|
||||
{language === 'ko' ? '마케팅 캠페인 진행 중!' : 'Marketing Campaign Active!'}
|
||||
</h2>
|
||||
<p className="text-primary-100">
|
||||
{campaign.start_date && campaign.end_date && (
|
||||
<>
|
||||
{new Date(campaign.start_date).toLocaleDateString()} ~{' '}
|
||||
{new Date(campaign.end_date).toLocaleDateString()}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold">{campaign.sns_share_reward_cc || 3} CC</div>
|
||||
<div className="text-sm text-primary-100">
|
||||
{language === 'ko' ? '공유당 보상' : 'per share'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Form */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 mb-8">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
{language === 'ko' ? 'SNS 공유 제출' : 'Submit SNS Share'}
|
||||
</h3>
|
||||
|
||||
{message && (
|
||||
<div
|
||||
className={`mb-4 p-4 rounded-lg ${
|
||||
message.type === 'success'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-red-100 text-red-700'
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Car Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{language === 'ko' ? '차량 선택' : 'Select Car'} *
|
||||
</label>
|
||||
{approvedCars.length > 0 ? (
|
||||
<select
|
||||
value={selectedCarId || ''}
|
||||
onChange={(e) => setSelectedCarId(Number(e.target.value) || null)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">
|
||||
{language === 'ko' ? '차량을 선택하세요' : 'Select a car'}
|
||||
</option>
|
||||
{approvedCars.map((car) => (
|
||||
<option key={car.id} value={car.id}>
|
||||
{car.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="p-4 bg-gray-100 rounded-lg text-gray-600 text-sm">
|
||||
{language === 'ko'
|
||||
? '추천받은 차량이 없습니다. 먼저 차량 추천을 요청해주세요.'
|
||||
: 'No recommended cars found. Please request vehicle recommendations first.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Platform Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{language === 'ko' ? 'SNS 플랫폼' : 'SNS Platform'} *
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
{PLATFORMS.map((platform) => (
|
||||
<button
|
||||
key={platform.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedPlatform(platform.id)}
|
||||
className={`flex-1 py-3 px-4 rounded-lg border-2 transition ${
|
||||
selectedPlatform === platform.id
|
||||
? 'border-primary-500 bg-primary-50 text-primary-700'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span className="text-2xl">{platform.icon}</span>
|
||||
<div className="text-sm mt-1">{platform.name}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SNS URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{language === 'ko' ? '게시물 URL' : 'Post URL'} *
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={snsUrl}
|
||||
onChange={(e) => setSnsUrl(e.target.value)}
|
||||
placeholder="https://..."
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{language === 'ko'
|
||||
? '차량을 공유한 SNS 게시물의 URL을 입력하세요'
|
||||
: 'Enter the URL of your SNS post sharing the vehicle'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !selectedCarId || !selectedPlatform || !snsUrl}
|
||||
className="w-full py-3 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition"
|
||||
>
|
||||
{submitting
|
||||
? (language === 'ko' ? '제출 중...' : 'Submitting...')
|
||||
: (language === 'ko' ? '제출하기' : 'Submit')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-8">
|
||||
<h4 className="font-medium text-blue-800 mb-2">
|
||||
{language === 'ko' ? '안내사항' : 'Information'}
|
||||
</h4>
|
||||
<ul className="text-sm text-blue-700 space-y-1">
|
||||
<li>
|
||||
{language === 'ko'
|
||||
? '• 제출 후 24시간 내에 관리자가 검증합니다.'
|
||||
: '• Admin will verify within 24 hours after submission.'}
|
||||
</li>
|
||||
<li>
|
||||
{language === 'ko'
|
||||
? `• 승인 시 ${campaign.sns_share_reward_cc || 3} CC가 지급됩니다.`
|
||||
: `• ${campaign.sns_share_reward_cc || 3} CC will be credited upon approval.`}
|
||||
</li>
|
||||
<li>
|
||||
{language === 'ko'
|
||||
? '• 같은 차량, 같은 URL은 중복 제출이 불가합니다.'
|
||||
: '• Duplicate submissions for the same car and URL are not allowed.'}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* My Submissions */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
{language === 'ko' ? '내 제출 내역' : 'My Submissions'}
|
||||
</h3>
|
||||
|
||||
{submissions.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{language === 'ko' ? '제출 내역이 없습니다.' : 'No submissions yet.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{submissions.map((sub) => (
|
||||
<div
|
||||
key={sub.id}
|
||||
className="border border-gray-200 rounded-lg p-4 flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-2xl">
|
||||
{PLATFORMS.find((p) => p.id === sub.platform)?.icon || '🔗'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-800">{sub.car_name || 'Car'}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{new Date(sub.submitted_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{sub.status === 'approved' && (
|
||||
<span className="text-green-600 font-medium">+{sub.reward_cc} CC</span>
|
||||
)}
|
||||
{getStatusBadge(sub.status)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1668,4 +1668,94 @@ export const visitorApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// SNS Share API
|
||||
export interface SnsShareSubmission {
|
||||
id: number;
|
||||
user_id: number;
|
||||
car_id: number;
|
||||
platform: string;
|
||||
sns_url: string;
|
||||
status: string;
|
||||
rejected_reason?: string;
|
||||
reward_cc: number;
|
||||
rewarded_at?: string;
|
||||
submitted_at: string;
|
||||
verified_at?: string;
|
||||
verified_by?: number;
|
||||
car_name?: string;
|
||||
car_image?: string;
|
||||
}
|
||||
|
||||
export interface SnsShareListResponse {
|
||||
submissions: SnsShareSubmission[];
|
||||
total: number;
|
||||
pending_count: number;
|
||||
approved_count: number;
|
||||
rejected_count: number;
|
||||
}
|
||||
|
||||
export interface SnsShareStats {
|
||||
total_submissions: number;
|
||||
pending_submissions: number;
|
||||
approved_submissions: number;
|
||||
rejected_submissions: number;
|
||||
total_rewarded_cc: number;
|
||||
}
|
||||
|
||||
export interface CampaignStatus {
|
||||
enabled: boolean;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
sns_share_reward_cc?: number;
|
||||
referral_signup_reward_cc?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const snsShareApi = {
|
||||
// Public: 캠페인 상태 조회
|
||||
getCampaignStatus: async (): Promise<CampaignStatus> => {
|
||||
const { data } = await api.get('/sns-share/campaign-status');
|
||||
return data;
|
||||
},
|
||||
|
||||
// User: SNS 공유 URL 제출
|
||||
submit: async (carId: number, platform: string, snsUrl: string): Promise<SnsShareSubmission> => {
|
||||
const { data } = await api.post('/sns-share/submit', {
|
||||
car_id: carId,
|
||||
platform,
|
||||
sns_url: snsUrl,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
// User: 내 제출 목록 조회
|
||||
getMySubmissions: async (): Promise<SnsShareListResponse> => {
|
||||
const { data } = await api.get('/sns-share/my-submissions');
|
||||
return data;
|
||||
},
|
||||
|
||||
// Admin: 전체 제출 목록 조회
|
||||
getAdminList: async (statusFilter?: string, skip: number = 0, limit: number = 50): Promise<SnsShareListResponse> => {
|
||||
const params: any = { skip, limit };
|
||||
if (statusFilter) params.status_filter = statusFilter;
|
||||
const { data } = await api.get('/sns-share/admin/list', { params });
|
||||
return data;
|
||||
},
|
||||
|
||||
// Admin: 통계 조회
|
||||
getAdminStats: async (): Promise<SnsShareStats> => {
|
||||
const { data } = await api.get('/sns-share/admin/stats');
|
||||
return data;
|
||||
},
|
||||
|
||||
// Admin: 검증 (승인/거부)
|
||||
verify: async (submissionId: number, approved: boolean, rejectedReason?: string): Promise<SnsShareSubmission> => {
|
||||
const { data } = await api.put(`/sns-share/admin/${submissionId}/verify`, {
|
||||
approved,
|
||||
rejected_reason: rejectedReason,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
Reference in New Issue
Block a user