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:
AutonetSellCar Deploy
2026-01-03 18:21:17 +09:00
parent 718c5b0474
commit 7c943d8553
17 changed files with 1414 additions and 6 deletions

View File

@@ -7,7 +7,7 @@ from pydantic import BaseModel
from typing import Optional from typing import Optional
import bcrypt import bcrypt
from ..database import get_db from ..database import get_db
from ..models import User from ..models import User, SystemSettings, ReferralReward
from ..models.user import generate_referral_code from ..models.user import generate_referral_code
from ..schemas import UserCreate, UserUpdate, UserResponse, Token from ..schemas import UserCreate, UserUpdate, UserResponse, Token
from ..config import get_settings from ..config import get_settings
@@ -151,6 +151,32 @@ def register(user_data: UserCreate, db: Session = Depends(get_db)):
db.add(user) db.add(user)
db.commit() db.commit()
db.refresh(user) 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 return user

View 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,
}

View File

@@ -9,7 +9,7 @@ import asyncio
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from .database import engine, Base, SessionLocal 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 .config import get_settings
from .services.exchange_rate_service import update_exchange_rates from .services.exchange_rate_service import update_exchange_rates
from .services.visitor_service import aggregate_daily_stats, cleanup_old_visitor_logs 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(exchange_rate.router)
app.include_router(verification.router, prefix="/api") app.include_router(verification.router, prefix="/api")
app.include_router(visitor.router, prefix="/api") app.include_router(visitor.router, prefix="/api")
app.include_router(sns_share.router, prefix="/api")
@app.get("/") @app.get("/")

View File

@@ -11,6 +11,7 @@ from .vehicle_share import VehicleShare, ShareReward
from .withdrawal import WithdrawalRequest from .withdrawal import WithdrawalRequest
from .referral import ReferralReward from .referral import ReferralReward
from .notification import Notification from .notification import Notification
from .sns_share import SnsShareSubmission
from .push_subscription import PushSubscription, UserNotificationPreference from .push_subscription import PushSubscription, UserNotificationPreference
from .performance_check import CarPerformanceCheck from .performance_check import CarPerformanceCheck
from .car_specification import CarSpecification from .car_specification import CarSpecification
@@ -52,6 +53,7 @@ __all__ = [
"WithdrawalRequest", "WithdrawalRequest",
"ReferralReward", "ReferralReward",
"Notification", "Notification",
"SnsShareSubmission",
"PushSubscription", "PushSubscription",
"UserNotificationPreference", "UserNotificationPreference",
"ExchangeRate", "ExchangeRate",

View File

@@ -16,10 +16,13 @@ class ReferralReward(Base):
# 피추천인 (추천받아 가입한 사람) # 피추천인 (추천받아 가입한 사람)
referred_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) referred_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
# 결제 금액 (피추천인이 충전한 금액 USD) # 보상 유형: "signup" (가입 시), "payment" (결제 시)
payment_amount = Column(Float, nullable=False) 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) reward_amount = Column(Float, nullable=False)
# 보상 상태: pending(대기), credited(적립), withdrawn(출금) # 보상 상태: pending(대기), credited(적립), withdrawn(출금)

View File

@@ -47,6 +47,22 @@ class SystemSettings(Base):
exchange_rate_weight_rub = Column(Float, default=0.0) # RUB (러시아 루블) 가중치 exchange_rate_weight_rub = Column(Float, default=0.0) # RUB (러시아 루블) 가중치
exchange_rate_weight_cny = Column(Float, default=0.0) # CNY (중국 위안) 가중치 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()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View 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])

View File

@@ -24,7 +24,8 @@ class User(Base):
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False) is_admin = Column(Boolean, default=False)
is_dealer = Column(Boolean, default=False) # Dealer status 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 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 referred_by = Column(String(8), nullable=True) # Referral code of the user who referred this user

View File

@@ -47,6 +47,10 @@ from .notification import (
NotificationCreate, NotificationResponse, NotificationCreate, NotificationResponse,
NotificationListResponse, NotificationMarkRead, NotificationListResponse, NotificationMarkRead,
) )
from .sns_share import (
SnsShareSubmit, SnsShareResponse, SnsShareListResponse,
SnsShareVerify, SnsShareStats,
)
__all__ = [ __all__ = [
"CarMakerCreate", "CarMakerResponse", "CarMakerCreate", "CarMakerResponse",
@@ -78,4 +82,6 @@ __all__ = [
"ReferralSettingsResponse", "ReferralSettingsUpdate", "ReferralSettingsResponse", "ReferralSettingsUpdate",
"NotificationCreate", "NotificationResponse", "NotificationCreate", "NotificationResponse",
"NotificationListResponse", "NotificationMarkRead", "NotificationListResponse", "NotificationMarkRead",
"SnsShareSubmit", "SnsShareResponse", "SnsShareListResponse",
"SnsShareVerify", "SnsShareStats",
] ]

View File

@@ -22,6 +22,16 @@ class SystemSettingsUpdate(BaseModel):
referral_reward_percent: Optional[float] = None referral_reward_percent: Optional[float] = None
referral_reward_type: Optional[str] = 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): class SystemSettingsResponse(BaseModel):
"""시스템 설정 응답 스키마""" """시스템 설정 응답 스키마"""
@@ -42,6 +52,17 @@ class SystemSettingsResponse(BaseModel):
referral_reward_enabled: bool = True referral_reward_enabled: bool = True
referral_reward_percent: float = 10.0 referral_reward_percent: float = 10.0
referral_reward_type: str = "one_time" 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 created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None

View 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

View 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;

View File

@@ -14,6 +14,7 @@ const menuItems = [
{ href: '/admin/dealers', label: 'Dealers', icon: '🤝' }, { href: '/admin/dealers', label: 'Dealers', icon: '🤝' },
{ href: '/admin/payments', label: 'Payments', icon: '💳' }, { href: '/admin/payments', label: 'Payments', icon: '💳' },
{ href: '/admin/withdrawals', label: 'Withdrawals', icon: '💸' }, { href: '/admin/withdrawals', label: 'Withdrawals', icon: '💸' },
{ href: '/admin/sns-shares', label: 'SNS Shares', icon: '📢' },
{ href: '/admin/notifications', label: 'Notifications', icon: '🔔' }, { href: '/admin/notifications', label: 'Notifications', icon: '🔔' },
{ href: '/admin/translations', label: 'Translations', icon: '🌐' }, { href: '/admin/translations', label: 'Translations', icon: '🌐' },
{ href: '/admin/dealer-translations', label: 'Dealer Descriptions', icon: '📝' }, { href: '/admin/dealer-translations', label: 'Dealer Descriptions', icon: '📝' },

View File

@@ -24,6 +24,15 @@ interface SystemSettings {
exchange_rate_weight_mnt: number; exchange_rate_weight_mnt: number;
exchange_rate_weight_rub: number; exchange_rate_weight_rub: number;
exchange_rate_weight_cny: 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 { interface ExchangeRateWeights {
@@ -66,6 +75,15 @@ export default function SettingsPage() {
referral_reward_enabled: true, referral_reward_enabled: true,
referral_reward_percent: 10.0, referral_reward_percent: 10.0,
referral_reward_type: 'one_time', 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(() => { useEffect(() => {
@@ -96,6 +114,15 @@ export default function SettingsPage() {
referral_reward_enabled: data.referral_reward_enabled ?? true, referral_reward_enabled: data.referral_reward_enabled ?? true,
referral_reward_percent: data.referral_reward_percent ?? 10.0, referral_reward_percent: data.referral_reward_percent ?? 10.0,
referral_reward_type: data.referral_reward_type || 'one_time', 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) { } catch (error) {
@@ -531,6 +558,152 @@ export default function SettingsPage() {
</div> </div>
</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 */} {/* Submit Button */}
<div className="flex justify-end"> <div className="flex justify-end">
<button <button

View 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>
);
}

View 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>
);
}

View File

@@ -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; export default api;