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.requestVehicle} - +
+
+ {/* Request Vehicle Button */} + + {t.requestVehicle} + + + {/* Divider */} +
+
+ + {/* Promo Preference */} + +
diff --git a/frontend/src/components/PromoPreference.tsx b/frontend/src/components/PromoPreference.tsx new file mode 100644 index 0000000..c7834d0 --- /dev/null +++ b/frontend/src/components/PromoPreference.tsx @@ -0,0 +1,213 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { carsApi, authApi } from '@/lib/api'; +import { useAuthStore } from '@/lib/store'; +import { useTranslation } from '@/lib/i18n'; +import { CarMaker, CarModel } from '@/types'; + +export default function PromoPreference() { + const { t } = useTranslation(); + const { user, token } = useAuthStore(); + const isLoggedIn = !!token && !!user; + + const [makers, setMakers] = useState([]); + const [models, setModels] = useState([]); + const [selectedMaker, setSelectedMaker] = useState(''); + const [selectedModel, setSelectedModel] = useState(''); + const [emailEnabled, setEmailEnabled] = useState(false); + const [loading, setLoading] = useState(false); + const [saved, setSaved] = useState(false); + const [showLoginPrompt, setShowLoginPrompt] = useState(false); + + // Load makers on mount + useEffect(() => { + const loadMakers = async () => { + try { + const data = await carsApi.getMakers(); + setMakers(data); + } catch (error) { + console.error('Failed to load makers:', error); + } + }; + loadMakers(); + }, []); + + // Load user preferences if logged in + useEffect(() => { + const loadPreferences = async () => { + if (!isLoggedIn) return; + try { + const pref = await authApi.getPromoPreference(); + if (pref.promo_preferred_maker) { + setSelectedMaker(pref.promo_preferred_maker); + // Load models for selected maker + const maker = makers.find(m => m.name === pref.promo_preferred_maker); + if (maker) { + const modelsData = await carsApi.getModels(maker.id); + setModels(modelsData); + } + } + if (pref.promo_preferred_model) { + setSelectedModel(pref.promo_preferred_model); + } + setEmailEnabled(pref.promo_email_enabled); + } catch (error) { + console.error('Failed to load preferences:', error); + } + }; + if (makers.length > 0) { + loadPreferences(); + } + }, [isLoggedIn, makers]); + + // Load models when maker changes + const handleMakerChange = async (makerName: string) => { + if (!isLoggedIn) { + setShowLoginPrompt(true); + return; + } + setShowLoginPrompt(false); + setSelectedMaker(makerName); + setSelectedModel(''); + setSaved(false); + + if (makerName) { + const maker = makers.find(m => m.name === makerName); + if (maker) { + try { + const data = await carsApi.getModels(maker.id); + setModels(data); + } catch (error) { + console.error('Failed to load models:', error); + } + } + } else { + setModels([]); + } + }; + + const handleModelChange = (modelName: string) => { + if (!isLoggedIn) { + setShowLoginPrompt(true); + return; + } + setShowLoginPrompt(false); + setSelectedModel(modelName); + setSaved(false); + }; + + const handleEmailToggle = () => { + if (!isLoggedIn) { + setShowLoginPrompt(true); + return; + } + setShowLoginPrompt(false); + setEmailEnabled(!emailEnabled); + setSaved(false); + }; + + const handleSave = async () => { + if (!isLoggedIn) { + setShowLoginPrompt(true); + return; + } + setLoading(true); + try { + await authApi.updatePromoPreference({ + promo_preferred_maker: selectedMaker || undefined, + promo_preferred_model: selectedModel || undefined, + promo_email_enabled: emailEnabled, + }); + setSaved(true); + setTimeout(() => setSaved(false), 3000); + } catch (error) { + console.error('Failed to save preferences:', error); + } finally { + setLoading(false); + } + }; + + return ( +
+

+ {t.promoPreferenceTitle || 'Next Promotion Interest'} +

+

+ {t.promoPreferenceDesc || 'Tell us which vehicle you want in the next promotion!'} +

+ + {/* Maker - Model Combo */} +
+
+ + + +
+ + {/* Email Alert Checkbox */} + + + {/* Login Prompt */} + {showLoginPrompt && ( +
+

+ {t.promoLoginRequired || 'Please login to save your preference'} +

+ + {t.login || 'Login'} / {t.signUp || 'Sign Up'} + +
+ )} + + {/* Save Button */} + {isLoggedIn && (selectedMaker || emailEnabled) && ( + + )} +
+
+ ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 94cd91b..666cb02 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,5 +1,5 @@ import axios from 'axios'; -import { Car, CarListResponse, CarMaker, CarModel, User, HeroBanner, HeroBannerSettings, CarView } from '@/types'; +import { Car, CarListResponse, CarMaker, CarModel, User, HeroBanner, HeroBannerSettings, CarView, PromoPreference } from '@/types'; // When NEXT_PUBLIC_API_URL is empty, use relative path (for HTTPS proxy setup) const API_URL = process.env.NEXT_PUBLIC_API_URL; @@ -133,6 +133,18 @@ export const authApi = { const { data } = await api.post('/auth/withdraw/cancel'); return data; }, + + // 프로모션 선호 차종 조회 + getPromoPreference: async (): Promise => { + const { data } = await api.get('/auth/me/promo-preference'); + return data; + }, + + // 프로모션 선호 차종 설정 + updatePromoPreference: async (preference: PromoPreference): Promise => { + const { data } = await api.put('/auth/me/promo-preference', preference); + return data; + }, }; // Inquiries API diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 95c9dfd..8287bd7 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -121,6 +121,17 @@ export interface Translations { contactForPrice: string; engine: string; + // Promo Preference + promoPreferenceTitle: string; + promoPreferenceDesc: string; + selectMaker: string; + selectModel: string; + promoEmailAlert: string; + promoLoginRequired: string; + savePreference: string; + saving: string; + saved: string; + // Vehicle Request & CC System requestVehicle: string; aiDealerSearching: string; @@ -592,6 +603,17 @@ const translations: Record = { contactForPrice: 'Contact for price', engine: 'Engine', + // Promo Preference + promoPreferenceTitle: 'Next Promotion Interest', + promoPreferenceDesc: 'Tell us which vehicle you want in the next promotion!', + selectMaker: 'Select Maker', + selectModel: 'Select Model', + promoEmailAlert: 'Notify me by email when this vehicle is on promotion', + promoLoginRequired: 'Please login to save your preference', + savePreference: 'Save Preference', + saving: 'Saving...', + saved: 'Saved!', + // Vehicle Request & CC System requestVehicle: 'Request Vehicle', aiDealerSearching: 'Korean AI Dealer is searching for vehicles...', @@ -1061,6 +1083,17 @@ const translations: Record = { contactForPrice: 'Үнийг лавлана уу', engine: 'Хөдөлгүүр', + // Promo Preference + promoPreferenceTitle: 'Дараагийн урамшуулалд сонирхолтой', + promoPreferenceDesc: 'Дараагийн урамшуулалд ямар машин хүсч байгаагаа хэлнэ үү!', + selectMaker: 'Үйлдвэрлэгч сонгох', + selectModel: 'Загвар сонгох', + promoEmailAlert: 'Энэ машин урамшуулалд гарахад имэйлээр мэдэгдэнэ үү', + promoLoginRequired: 'Сонголтоо хадгалахын тулд нэвтэрнэ үү', + savePreference: 'Сонголт хадгалах', + saving: 'Хадгалж байна...', + saved: 'Хадгалагдлаа!', + // Vehicle Request & CC System requestVehicle: 'Машин захиалах', aiDealerSearching: 'Солонгосын AI дилер машин хайж байна...', @@ -1530,6 +1563,17 @@ const translations: Record = { contactForPrice: 'Цена по запросу', engine: 'Двигатель', + // Promo Preference + promoPreferenceTitle: 'Интерес к следующей акции', + promoPreferenceDesc: 'Расскажите, какой автомобиль вы хотите в следующей акции!', + selectMaker: 'Выберите марку', + selectModel: 'Выберите модель', + promoEmailAlert: 'Уведомить по email когда этот автомобиль появится в акции', + promoLoginRequired: 'Войдите, чтобы сохранить предпочтения', + savePreference: 'Сохранить предпочтения', + saving: 'Сохранение...', + saved: 'Сохранено!', + // Vehicle Request & CC System requestVehicle: 'Запросить автомобиль', aiDealerSearching: 'Корейский AI-дилер ищет автомобили...', @@ -1999,6 +2043,17 @@ const translations: Record = { contactForPrice: '가격 문의', engine: '엔진', + // Promo Preference + promoPreferenceTitle: '다음 프로모션 관심 차종', + promoPreferenceDesc: '다음 프로모션에 원하는 차량을 알려주세요!', + selectMaker: '제조사 선택', + selectModel: '모델 선택', + promoEmailAlert: '이 차량이 프로모션에 등록되면 이메일로 알림받기', + promoLoginRequired: '선호도를 저장하려면 로그인하세요', + savePreference: '선호도 저장', + saving: '저장 중...', + saved: '저장됨!', + // Vehicle Request & CC System requestVehicle: '차량요청하기', aiDealerSearching: '한국AI딜러가 차량을 찾고있습니다...', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 514ea5c..f12972c 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -106,9 +106,18 @@ export interface User { referral_code?: string; email_verified: boolean; phone_verified: boolean; + promo_preferred_maker?: string; + promo_preferred_model?: string; + promo_email_enabled?: boolean; created_at: string; } +export interface PromoPreference { + promo_preferred_maker?: string; + promo_preferred_model?: string; + promo_email_enabled: boolean; +} + export interface CarView { id: number; user_id: number;