feat: Add promo preference survey on main page
- Add promo preference fields to User model (promo_preferred_maker, promo_preferred_model, promo_email_enabled) - Create API endpoints for getting/updating promo preferences - Create PromoPreference component with maker/model selection - Show login prompt for non-logged-in users when interacting - Add promo notification service to send emails when matching vehicles are added to promotion - Add multi-language translations (en, mn, ru, ko) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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) # 탈퇴 사유
|
||||
|
||||
@@ -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
|
||||
|
||||
254
backend/app/services/promo_notification_service.py
Normal file
254
backend/app/services/promo_notification_service.py
Normal file
@@ -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))
|
||||
@@ -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 */}
|
||||
<FilmStripSlider banners={banners} settings={bannerSettings} />
|
||||
|
||||
<div className="container mx-auto px-4 py-4 sm:py-8 text-center">
|
||||
<div className="container mx-auto px-4 py-4 sm:py-8">
|
||||
<div className="flex flex-col lg:flex-row items-center justify-center gap-4 lg:gap-8">
|
||||
{/* Request Vehicle Button */}
|
||||
<Link
|
||||
href="/vehicle-request"
|
||||
className="inline-block bg-yellow-500 text-white font-semibold px-6 py-2.5 sm:px-8 sm:py-3 rounded-lg hover:bg-yellow-600 transition shadow-lg text-sm sm:text-base"
|
||||
>
|
||||
{t.requestVehicle}
|
||||
</Link>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="hidden lg:block w-px h-24 bg-white/30"></div>
|
||||
<div className="lg:hidden w-32 h-px bg-white/30"></div>
|
||||
|
||||
{/* Promo Preference */}
|
||||
<PromoPreference />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
213
frontend/src/components/PromoPreference.tsx
Normal file
213
frontend/src/components/PromoPreference.tsx
Normal file
@@ -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<CarMaker[]>([]);
|
||||
const [models, setModels] = useState<CarModel[]>([]);
|
||||
const [selectedMaker, setSelectedMaker] = useState<string>('');
|
||||
const [selectedModel, setSelectedModel] = useState<string>('');
|
||||
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 (
|
||||
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-4 sm:p-5 max-w-md mx-auto">
|
||||
<h3 className="text-base sm:text-lg font-semibold text-white mb-3 text-center">
|
||||
{t.promoPreferenceTitle || 'Next Promotion Interest'}
|
||||
</h3>
|
||||
<p className="text-xs sm:text-sm text-primary-100 mb-4 text-center">
|
||||
{t.promoPreferenceDesc || 'Tell us which vehicle you want in the next promotion!'}
|
||||
</p>
|
||||
|
||||
{/* Maker - Model Combo */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<select
|
||||
value={selectedMaker}
|
||||
onChange={(e) => handleMakerChange(e.target.value)}
|
||||
className="flex-1 px-3 py-2 rounded-lg text-sm text-gray-800 bg-white border-0 focus:ring-2 focus:ring-yellow-500"
|
||||
>
|
||||
<option value="">{t.selectMaker || 'Select Maker'}</option>
|
||||
{makers.map((maker) => (
|
||||
<option key={maker.id} value={maker.name}>
|
||||
{maker.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => handleModelChange(e.target.value)}
|
||||
disabled={!selectedMaker}
|
||||
className="flex-1 px-3 py-2 rounded-lg text-sm text-gray-800 bg-white border-0 focus:ring-2 focus:ring-yellow-500 disabled:bg-gray-100 disabled:text-gray-400"
|
||||
>
|
||||
<option value="">{t.selectModel || 'Select Model'}</option>
|
||||
{models.map((model) => (
|
||||
<option key={model.id} value={model.name}>
|
||||
{model.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Email Alert Checkbox */}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={emailEnabled}
|
||||
onChange={handleEmailToggle}
|
||||
className="w-4 h-4 text-yellow-500 bg-white rounded border-0 focus:ring-yellow-500"
|
||||
/>
|
||||
<span className="text-xs sm:text-sm text-white">
|
||||
{t.promoEmailAlert || 'Notify me by email when this vehicle is on promotion'}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Login Prompt */}
|
||||
{showLoginPrompt && (
|
||||
<div className="p-3 bg-yellow-500/20 rounded-lg text-center">
|
||||
<p className="text-sm text-white mb-2">
|
||||
{t.promoLoginRequired || 'Please login to save your preference'}
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-block text-sm font-medium text-yellow-400 hover:text-yellow-300 underline"
|
||||
>
|
||||
{t.login || 'Login'} / {t.signUp || 'Sign Up'}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save Button */}
|
||||
{isLoggedIn && (selectedMaker || emailEnabled) && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
className="w-full py-2 bg-yellow-500 text-white font-medium rounded-lg hover:bg-yellow-600 transition disabled:opacity-50 text-sm"
|
||||
>
|
||||
{loading ? (t.saving || 'Saving...') : saved ? (t.saved || 'Saved!') : (t.savePreference || 'Save Preference')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<PromoPreference> => {
|
||||
const { data } = await api.get('/auth/me/promo-preference');
|
||||
return data;
|
||||
},
|
||||
|
||||
// 프로모션 선호 차종 설정
|
||||
updatePromoPreference: async (preference: PromoPreference): Promise<PromoPreference> => {
|
||||
const { data } = await api.put('/auth/me/promo-preference', preference);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
// Inquiries API
|
||||
|
||||
@@ -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<Language, Translations> = {
|
||||
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<Language, Translations> = {
|
||||
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<Language, Translations> = {
|
||||
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<Language, Translations> = {
|
||||
contactForPrice: '가격 문의',
|
||||
engine: '엔진',
|
||||
|
||||
// Promo Preference
|
||||
promoPreferenceTitle: '다음 프로모션 관심 차종',
|
||||
promoPreferenceDesc: '다음 프로모션에 원하는 차량을 알려주세요!',
|
||||
selectMaker: '제조사 선택',
|
||||
selectModel: '모델 선택',
|
||||
promoEmailAlert: '이 차량이 프로모션에 등록되면 이메일로 알림받기',
|
||||
promoLoginRequired: '선호도를 저장하려면 로그인하세요',
|
||||
savePreference: '선호도 저장',
|
||||
saving: '저장 중...',
|
||||
saved: '저장됨!',
|
||||
|
||||
// Vehicle Request & CC System
|
||||
requestVehicle: '차량요청하기',
|
||||
aiDealerSearching: '한국AI딜러가 차량을 찾고있습니다...',
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user