From 7c943d85532b87b1a144cdae09b01b0c6b4e0d2f Mon Sep 17 00:00:00 2001 From: AutonetSellCar Deploy Date: Sat, 3 Jan 2026 18:21:17 +0900 Subject: [PATCH] Add SNS Marketing Campaign feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/app/api/auth.py | 28 +- backend/app/api/sns_share.py | 277 ++++++++++++++ backend/app/main.py | 3 +- backend/app/models/__init__.py | 2 + backend/app/models/referral.py | 9 +- backend/app/models/settings.py | 16 + backend/app/models/sns_share.py | 35 ++ backend/app/models/user.py | 3 +- backend/app/schemas/__init__.py | 6 + backend/app/schemas/settings.py | 21 ++ backend/app/schemas/sns_share.py | 57 +++ backend/migrations/marketing_campaign_v1.sql | 45 +++ frontend/src/app/admin/layout.tsx | 1 + frontend/src/app/admin/settings/page.tsx | 173 +++++++++ frontend/src/app/admin/sns-shares/page.tsx | 281 ++++++++++++++ frontend/src/app/sns-share/page.tsx | 373 +++++++++++++++++++ frontend/src/lib/api.ts | 90 +++++ 17 files changed, 1414 insertions(+), 6 deletions(-) create mode 100644 backend/app/api/sns_share.py create mode 100644 backend/app/models/sns_share.py create mode 100644 backend/app/schemas/sns_share.py create mode 100644 backend/migrations/marketing_campaign_v1.sql create mode 100644 frontend/src/app/admin/sns-shares/page.tsx create mode 100644 frontend/src/app/sns-share/page.tsx diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 4b36fcc..b536b03 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -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 diff --git a/backend/app/api/sns_share.py b/backend/app/api/sns_share.py new file mode 100644 index 0000000..ae4149c --- /dev/null +++ b/backend/app/api/sns_share.py @@ -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, + } diff --git a/backend/app/main.py b/backend/app/main.py index b766887..e26f941 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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("/") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index e51b5d7..2ee2c8d 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/referral.py b/backend/app/models/referral.py index 147ce9e..79fa892 100644 --- a/backend/app/models/referral.py +++ b/backend/app/models/referral.py @@ -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(์ถœ๊ธˆ) diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py index 92b8b28..ca818f9 100644 --- a/backend/app/models/settings.py +++ b/backend/app/models/settings.py @@ -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()) diff --git a/backend/app/models/sns_share.py b/backend/app/models/sns_share.py new file mode 100644 index 0000000..e2d40f2 --- /dev/null +++ b/backend/app/models/sns_share.py @@ -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]) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 944218f..d65c802 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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 diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 986695e..5fac6f8 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -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", ] diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 77405a4..a9f941d 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -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 diff --git a/backend/app/schemas/sns_share.py b/backend/app/schemas/sns_share.py new file mode 100644 index 0000000..22b90f4 --- /dev/null +++ b/backend/app/schemas/sns_share.py @@ -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 diff --git a/backend/migrations/marketing_campaign_v1.sql b/backend/migrations/marketing_campaign_v1.sql new file mode 100644 index 0000000..8cc726a --- /dev/null +++ b/backend/migrations/marketing_campaign_v1.sql @@ -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; diff --git a/frontend/src/app/admin/layout.tsx b/frontend/src/app/admin/layout.tsx index 9e0dac7..ce39fb2 100644 --- a/frontend/src/app/admin/layout.tsx +++ b/frontend/src/app/admin/layout.tsx @@ -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: '๐Ÿ“' }, diff --git a/frontend/src/app/admin/settings/page.tsx b/frontend/src/app/admin/settings/page.tsx index ee87b70..a24c9b8 100644 --- a/frontend/src/app/admin/settings/page.tsx +++ b/frontend/src/app/admin/settings/page.tsx @@ -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() { + {/* Marketing Campaign Settings */} +
+

+ ๐Ÿ“ข Marketing Campaign Settings +

+ +
+ {/* Campaign Toggle */} +
+ +
+ Enable Marketing Campaign +

SNS ๊ณต์œ  ๋ฐ ๋ ˆํผ๋Ÿด ๋ณด์ƒ ์บ ํŽ˜์ธ ํ™œ์„ฑํ™”

+
+
+ + {/* Campaign Period */} +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + {/* Reward Settings */} +
+
+ + 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" + /> +

SNS ๊ณต์œ  ์Šน์ธ ์‹œ ์ง€๊ธ‰๋˜๋Š” CC

+
+
+ + 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" + /> +

์นœ๊ตฌ๊ฐ€ ๊ฐ€์ž… ์‹œ ์ถ”์ฒœ์ธ์—๊ฒŒ ์ง€๊ธ‰๋˜๋Š” CC

+
+
+ +
+
+ + 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" + /> +

์ด๋ฒคํŠธ CC ์œ ํšจ๊ธฐ๊ฐ„

+
+
+ + {/* Withdrawal Settings */} +
+

Withdrawal Settings

+
+ + Enable Withdrawals +
+ +
+ + 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" + /> +
+
+ + {/* Campaign Preview */} +
+

+ Campaign Status: {formData.marketing_enabled ? 'ACTIVE' : 'INACTIVE'} +

+
+

SNS Share: {formData.sns_share_reward_cc} CC per approved share

+

Referral Signup: {formData.referral_signup_reward_cc} CC per new signup

+

Period: {formData.marketing_start_date || 'Not set'} ~ {formData.marketing_end_date || 'Not set'}

+
+
+
+
+ {/* Submit Button */}
+ ))} +
+ + + {/* Submissions Table */} +
+ + + + + + + + + + + + + {submissions.length === 0 ? ( + + + + ) : ( + submissions.map((sub) => ( + + + + + + + + + )) + )} + +
PlatformCarUserSubmittedStatusActions
+ No submissions found +
+
+ {PLATFORMS[sub.platform]?.icon || '๐Ÿ”—'} + {PLATFORMS[sub.platform]?.name || sub.platform} +
+
+
{sub.car_name || `Car #${sub.car_id}`}
+
User #{sub.user_id} + {new Date(sub.submitted_at).toLocaleString()} + {getStatusBadge(sub.status)} +
+ + View Post + + {sub.status === 'pending' && ( + + )} +
+
+
+ + + {/* Verification Modal */} + {selectedSubmission && ( +
+
+

Verify Submission

+ +
+
+ {PLATFORMS[selectedSubmission.platform]?.icon} +
+
{selectedSubmission.car_name || 'Car'}
+
+ User #{selectedSubmission.user_id} +
+
+
+ + {selectedSubmission.sns_url} + +
+ +
+ +