diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py
index b536b03..671e752 100644
--- a/backend/app/api/auth.py
+++ b/backend/app/api/auth.py
@@ -10,6 +10,7 @@ from ..database import get_db
from ..models import User, SystemSettings, ReferralReward
from ..models.user import generate_referral_code
from ..schemas import UserCreate, UserUpdate, UserResponse, Token
+from ..schemas.user import PromoPreferenceUpdate, PromoPreferenceResponse
from ..config import get_settings
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -277,6 +278,28 @@ def update_me(
return current_user
+@router.get("/me/promo-preference", response_model=PromoPreferenceResponse)
+def get_promo_preference(current_user: User = Depends(get_current_user)):
+ """프로모션 선호 차종 조회"""
+ return current_user
+
+
+@router.put("/me/promo-preference", response_model=PromoPreferenceResponse)
+def update_promo_preference(
+ promo_update: PromoPreferenceUpdate,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """프로모션 선호 차종 설정"""
+ current_user.promo_preferred_maker = promo_update.promo_preferred_maker
+ current_user.promo_preferred_model = promo_update.promo_preferred_model
+ current_user.promo_email_enabled = promo_update.promo_email_enabled
+
+ db.commit()
+ db.refresh(current_user)
+ return current_user
+
+
# Admin User Management Endpoints
@router.get("/admin/users")
def admin_get_users(
diff --git a/backend/app/api/hero_banners.py b/backend/app/api/hero_banners.py
index 4dbf8c7..aac0554 100644
--- a/backend/app/api/hero_banners.py
+++ b/backend/app/api/hero_banners.py
@@ -1,4 +1,4 @@
-from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Body
+from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Body, BackgroundTasks
from sqlalchemy.orm import Session
from typing import List, Optional
import os
@@ -17,6 +17,7 @@ from ..schemas.hero_banner import (
from .auth import get_current_user
from ..models import User
from ..config import get_settings
+from ..services.promo_notification_service import notify_users_for_promo_vehicle
router = APIRouter(prefix="/hero-banners", tags=["hero-banners"])
@@ -309,15 +310,16 @@ async def upload_banner_image(
# ==================== Banner Toggle & Ordering ====================
@router.post("/admin/toggle/{car_id}")
-def toggle_banner(
+async def toggle_banner(
car_id: int,
+ background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user)
):
"""차량의 배너 상태 토글 (Admin)
- HeroBanner 존재 → 삭제
- - HeroBanner 없음 → 생성
+ - HeroBanner 없음 → 생성 (+ 프로모션 알림 이메일 전송)
"""
car = db.query(Car).filter(Car.id == car_id).first()
if not car:
@@ -360,4 +362,8 @@ def toggle_banner(
car.is_banner = True
db.commit()
db.refresh(banner)
+
+ # Send promo notification emails in background
+ background_tasks.add_task(notify_users_for_promo_vehicle, db, car)
+
return {"car_id": car_id, "is_banner": True, "banner_id": banner.id, "message": "Added to banner"}
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
index d65c802..d258bb9 100644
--- a/backend/app/models/user.py
+++ b/backend/app/models/user.py
@@ -37,6 +37,11 @@ class User(Base):
phone_verified = Column(Boolean, default=False)
phone_verified_at = Column(DateTime(timezone=True), nullable=True)
+ # Promotion preference (프로모션 선호 차종)
+ promo_preferred_maker = Column(String(100), nullable=True) # 선호 메이커명
+ promo_preferred_model = Column(String(100), nullable=True) # 선호 모델명
+ promo_email_enabled = Column(Boolean, default=False) # 프로모션 이메일 수신 동의
+
# Account withdrawal/deletion
withdrawal_requested_at = Column(DateTime(timezone=True), nullable=True) # 탈퇴 요청 시각
withdrawal_reason = Column(String(500), nullable=True) # 탈퇴 사유
diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py
index df7dc6a..eabaab3 100644
--- a/backend/app/schemas/user.py
+++ b/backend/app/schemas/user.py
@@ -42,12 +42,31 @@ class UserResponse(BaseModel):
referral_code: Optional[str] = None # User's unique referral code
email_verified: bool = False
phone_verified: bool = False
+ promo_preferred_maker: Optional[str] = None
+ promo_preferred_model: Optional[str] = None
+ promo_email_enabled: bool = False
created_at: datetime
class Config:
from_attributes = True
+class PromoPreferenceUpdate(BaseModel):
+ """Schema for updating promo preference"""
+ promo_preferred_maker: Optional[str] = None
+ promo_preferred_model: Optional[str] = None
+ promo_email_enabled: bool = False
+
+
+class PromoPreferenceResponse(BaseModel):
+ promo_preferred_maker: Optional[str] = None
+ promo_preferred_model: Optional[str] = None
+ promo_email_enabled: bool = False
+
+ class Config:
+ from_attributes = True
+
+
class CarViewResponse(BaseModel):
id: int
user_id: int
diff --git a/backend/app/services/promo_notification_service.py b/backend/app/services/promo_notification_service.py
new file mode 100644
index 0000000..80ff5a3
--- /dev/null
+++ b/backend/app/services/promo_notification_service.py
@@ -0,0 +1,254 @@
+"""
+Promo Notification Service
+Sends email notifications to users when their preferred vehicle is added to a promotion
+"""
+import smtplib
+from email.mime.text import MIMEText
+from email.mime.multipart import MIMEMultipart
+from typing import List, Optional
+from sqlalchemy.orm import Session
+import asyncio
+
+from ..config import get_settings
+from ..models.user import User
+from ..models.car import Car
+
+settings = get_settings()
+
+
+def get_users_with_matching_preference(
+ db: Session,
+ maker_name: Optional[str] = None,
+ model_name: Optional[str] = None
+) -> List[User]:
+ """
+ Find users whose promo preference matches the given maker/model
+ Only returns users who have promo_email_enabled = True
+ """
+ query = db.query(User).filter(
+ User.promo_email_enabled == True,
+ User.email.isnot(None)
+ )
+
+ if maker_name:
+ # Match maker only (for users who selected maker but any model)
+ # or match exact maker+model
+ query = query.filter(User.promo_preferred_maker == maker_name)
+
+ if model_name:
+ # If model is specified, also filter by model (or users with no model preference)
+ query = query.filter(
+ (User.promo_preferred_model == model_name) |
+ (User.promo_preferred_model.is_(None)) |
+ (User.promo_preferred_model == "")
+ )
+
+ return query.all()
+
+
+async def send_promo_notification_email(
+ email: str,
+ car: Car,
+ language: str = "en"
+) -> bool:
+ """Send promo notification email to a single user"""
+ try:
+ # Email templates by language
+ subjects = {
+ "en": f"AutonetSellCar - Your Preferred Vehicle is Now on Promotion!",
+ "ko": f"AutonetSellCar - 관심 차량이 프로모션에 등록되었습니다!",
+ "mn": f"AutonetSellCar - Таны сонирхсон машин урамшуулалд орлоо!",
+ "ru": f"AutonetSellCar - Ваш желаемый автомобиль теперь в акции!"
+ }
+
+ car_name = car.car_name or "Unknown Vehicle"
+ car_year = car.year or ""
+ car_mileage = f"{car.mileage:,}km" if car.mileage else ""
+ car_url = f"https://autonetsellcar.com/cars/{car.id}"
+
+ bodies = {
+ "en": f"""
+Hello,
+
+Great news! A vehicle matching your preference is now available on promotion!
+
+Vehicle: {car_name}
+Year: {car_year}
+Mileage: {car_mileage}
+
+View this vehicle: {car_url}
+
+As a promoted vehicle, you can view all photos for free!
+
+Best regards,
+AutonetSellCar Team
+
+---
+You received this email because you signed up for promotion notifications.
+To unsubscribe, update your preferences in your account settings.
+""",
+ "ko": f"""
+안녕하세요,
+
+좋은 소식입니다! 관심 차량이 프로모션에 등록되었습니다!
+
+차량: {car_name}
+연식: {car_year}
+주행거리: {car_mileage}
+
+차량 보기: {car_url}
+
+프로모션 차량은 모든 사진을 무료로 보실 수 있습니다!
+
+감사합니다,
+AutonetSellCar 팀
+
+---
+프로모션 알림 신청으로 이 이메일을 받으셨습니다.
+수신 거부를 원하시면 계정 설정에서 변경해 주세요.
+""",
+ "mn": f"""
+Сайн байна уу,
+
+Сайхан мэдээ! Таны сонирхсон машин урамшуулалд орлоо!
+
+Машин: {car_name}
+Он: {car_year}
+Гүйлт: {car_mileage}
+
+Машин үзэх: {car_url}
+
+Урамшуулалтай машины бүх зургийг үнэгүй үзэх боломжтой!
+
+Хүндэтгэсэн,
+AutonetSellCar баг
+
+---
+Та урамшуулалын мэдэгдэл хүлээн авахаар бүртгүүлсэн тул энэ имэйлийг хүлээн авлаа.
+Татгалзахыг хүсвэл данс тохиргооноос өөрчилнө үү.
+""",
+ "ru": f"""
+Здравствуйте,
+
+Отличные новости! Автомобиль, соответствующий вашим предпочтениям, теперь в акции!
+
+Автомобиль: {car_name}
+Год: {car_year}
+Пробег: {car_mileage}
+
+Посмотреть автомобиль: {car_url}
+
+Как акционный автомобиль, вы можете просмотреть все фотографии бесплатно!
+
+С уважением,
+Команда AutonetSellCar
+
+---
+Вы получили это письмо, потому что подписались на уведомления об акциях.
+Чтобы отписаться, измените настройки в вашем аккаунте.
+"""
+ }
+
+ subject = subjects.get(language, subjects["en"])
+ body = bodies.get(language, bodies["en"])
+
+ # Check if SMTP is configured
+ if not settings.SMTP_USER or not settings.SMTP_PASSWORD:
+ # Development mode - just log
+ print(f"[DEV] Promo notification email for {email}: {car_name}")
+ return True
+
+ # Send actual email
+ msg = MIMEMultipart()
+ msg['From'] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_FROM_EMAIL or settings.SMTP_USER}>"
+ msg['To'] = email
+ msg['Subject'] = subject
+ msg.attach(MIMEText(body, 'plain', 'utf-8'))
+
+ with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server:
+ server.starttls()
+ server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
+ server.send_message(msg)
+
+ print(f"[INFO] Promo notification sent to {email} for car {car.id}")
+ return True
+
+ except Exception as e:
+ print(f"[ERROR] Failed to send promo notification to {email}: {e}")
+ return False
+
+
+async def notify_users_for_promo_vehicle(
+ db: Session,
+ car: Car
+) -> int:
+ """
+ Notify all users whose preference matches the car being promoted
+ Returns the number of notifications sent
+ """
+ # Get maker and model names from car
+ maker_name = car.maker.name if car.maker else None
+ model_name = car.model.name if car.model else None
+
+ if not maker_name:
+ print(f"[INFO] Car {car.id} has no maker, skipping promo notifications")
+ return 0
+
+ # Find matching users
+ matching_users = get_users_with_matching_preference(
+ db=db,
+ maker_name=maker_name,
+ model_name=model_name
+ )
+
+ if not matching_users:
+ print(f"[INFO] No users with matching preference for {maker_name} {model_name}")
+ return 0
+
+ print(f"[INFO] Found {len(matching_users)} users with preference for {maker_name} {model_name}")
+
+ # Send notifications
+ sent_count = 0
+ for user in matching_users:
+ if user.email:
+ # Determine language based on user's country
+ language = "en" # Default
+ if user.country:
+ country_lower = user.country.lower()
+ if country_lower in ["korea", "south korea", "kr", "한국"]:
+ language = "ko"
+ elif country_lower in ["mongolia", "mn", "монгол"]:
+ language = "mn"
+ elif country_lower in ["russia", "ru", "россия"]:
+ language = "ru"
+
+ success = await send_promo_notification_email(
+ email=user.email,
+ car=car,
+ language=language
+ )
+ if success:
+ sent_count += 1
+
+ return sent_count
+
+
+def notify_users_for_promo_vehicle_sync(db: Session, car: Car) -> int:
+ """Synchronous wrapper for notify_users_for_promo_vehicle"""
+ try:
+ loop = asyncio.get_event_loop()
+ except RuntimeError:
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+
+ if loop.is_running():
+ # If already in async context, create a task
+ import concurrent.futures
+ with concurrent.futures.ThreadPoolExecutor() as executor:
+ future = executor.submit(
+ asyncio.run,
+ notify_users_for_promo_vehicle(db, car)
+ )
+ return future.result()
+ else:
+ return loop.run_until_complete(notify_users_for_promo_vehicle(db, car))
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index b65ee52..cef2fc1 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -3,6 +3,7 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
import FilmStripSlider from '@/components/FilmStripSlider';
+import PromoPreference from '@/components/PromoPreference';
import { HeroBanner, HeroBannerSettings } from '@/types';
import { heroBannersApi } from '@/lib/api';
import { useTranslation } from '@/lib/i18n';
@@ -45,13 +46,23 @@ export default function Home() {
{/* Film Strip Slider */}
+ {t.promoPreferenceDesc || 'Tell us which vehicle you want in the next promotion!'} +
+ + {/* Maker - Model Combo */} ++ {t.promoLoginRequired || 'Please login to save your preference'} +
+ + {t.login || 'Login'} / {t.signUp || 'Sign Up'} + +